Sommaire

BONJOUR
Musique "Invention BWV784" J.S. BACH 1685-1750

Le Tutoriel

Start C


Tutoriel SDL

C (langage)

Un article de Wikipédia, l'encyclopédie libre.

C
Apparu en | 1972
Auteur | Dennis Ritchie
Développeur | Dennis Ritchie, Bell Labs
Paradigme | Impératif, procédural, structuré
Typage | Statique, faible
Normes | ANSI X3.159-1989 (ANSI C, C89) ISO/CEI 9899:1990 (C90) ISO/CEI 9899:1999 (C99) ISO/IEC 9899:2011 (C11)
Influencé par | B (CPL, BCPL), ALGOL 60, Lisp
A influencé | awk, csh, C++, C#, Objective C, D, Concurrent C, Java, JavaScript, PHP, Perl
Implémentations | GCC, MSVC, Borland C, Clang, Watcom C
modifier Consultez la documentation du modèle

Le C est un langage de programmation impératif, généraliste, issu de la programmation système. Inventé au début des années 1970 pour réécrire UNIX, C est devenu un des langages les plus utilisés. De nombreux langages plus modernes comme C++, Java et PHP reprennent des aspects de C.

Sommaire

  • 1 Histoire
    • 1.1 Influences subies
    • 1.2 Normalisations
  • 2 Caractéristiques générales
    • 2.1 Qualités et défauts
    • 2.2 Aperçu de la syntaxe
      • 2.2.1 Hello world
      • 2.2.2 Brièveté de la syntaxe
      • 2.2.3 Langage d'expressions
  • 3 Des sources à l'exécutable
    • 3.1 Sources
    • 3.2 Précompilation
    • 3.3 Compilation
    • 3.4 Assemblage
    • 3.5 Édition des liens
  • 4 Éléments du langage
    • 4.1 Éléments lexicaux
    • 4.2 Fonctions
      • 4.2.1 Prototype
    • 4.3 Mots clés
    • 4.4 Instructions du préprocesseur
    • 4.5 Types
      • 4.5.1 Le type void *
    • 4.6 Structures
    • 4.7 Commentaire
    • 4.8 Structures de contrôle
    • 4.9 Comportements ambigus
  • 5 Bibliothèques logicielles
    • 5.1 La bibliothèque standard
    • 5.2 Les bibliothèques externes
  • 6 Exemples
    • 6.1 Allocation mémoire
  • 7 Références
  • 8 Voir aussi
    • 8.1 Articles connexes
    • 8.2 Bibliographie
    • 8.3 Liens externes

Histoire

Kenneth Thompson (à gauche) et Dennis Ritchie (à droite).

Le langage C est apparu au cours de l'année 1972 dans les Laboratoires Bell. Il était développé en même temps que UNIX par Dennis Ritchie et Ken Thompson. Ken Thompson avait développé un prédécesseur de C, le langage B, qui est lui-même inspiré de BCPL. Dennis Ritchie a fait évoluer le langage B dans une nouvelle version suffisamment différente pour qu'elle soit appelée C. Par la suite, Brian Kernighan aida à populariser le langage. Il procéda aussi à quelques modifications de dernière minute. En 1978, il fut notamment le principal auteur du livre The C Programming Language décrivant le langage enfin stabilisé ; Ritchie s'était occupé des appendices et des exemples avec Unix. On parle encore de C K&R, K&R C en anglais (pour Kernighan and Ritchie C) lorsqu'on se réfère au langage tel qu'il existait à cette époque.

Influences subies

Bien que C soit officiellement inspiré de B et de BCPL, on note une forte influence de PL/1 (ou de PL360) ; on a pu dire que C était à Unix et au PDP11 ce que PL1 fut pour la réécriture de Multics.

Normalisations

Inventé au début des années 1970, le langage a été complètement décrit pour la première fois en 1978 dans le livre The C Programming Language de Brian Kernighan et Dennis Ritchie. On appelle généralement C traditionnel ou K&R C ce premier langage.

En 1983, l'Institut national américain de normalisation (ANSI) a formé un comité de normalisation (X3J11) du langage qui a abouti en 1989 à la norme dite ANSI C ou C89 (formellement ANSI X3.159-1989). En 1990, cette norme a également été adoptée par l'Organisation internationale de normalisation (C90, C ISO, formellement ISO/CEI 9899:1990). ANSI C est une évolution du C K&R qui reste extrêmement compatible. Elle reprend quelques idées de C++, notamment la notion de prototype et les qualificateurs de type[1].

Entre 1994 et 1996, le groupe de travail de l'ISO (ISO/IEC JTC1/SC22/WG14) a publié deux correctifs et un amendement à C90 : ISO/IEC 9899/COR1:1994 Technical Corrigendum 1, ISO/IEC 9899/AMD1:1995 Intégrité de C, ISO/IEC 9899/COR1:1996 Technical Corrigendum 2. Ces changements assez modestes sont parfois appelés C89 avec amendement 1, ou C94 / C95[2],[3]. Trois fichiers d'entêtes ont été ajoutés, dont deux concernant les caractères larges et un autre définissant un certain nombre de macros en rapport avec la norme de caractères ISO 646.

En 1999, une nouvelle évolution du langage est normalisée par l'ISO : C99 (formellement ISO/CEI 9899:1999). Les nouveautés portent notamment sur les tableaux de taille variable, les pointeurs restreints, les nombres complexes, les littéraux composés, les déclarations mélangées avec les instructions, les fonctions inline, le support avancé des nombres flottants, et la syntaxe de commentaire de C++. La bibliothèque standard du C99 a été enrichie de six fichiers d'en-tête depuis la précédente norme.

En 2011, l'ISO ratifie une nouvelle version[4] du standard : C11, formellement ISO/IEC 9899:2011.

Caractéristiques générales

C est un langage de programmation impératif et généraliste. C est qualifié de langage de bas niveau dans le sens où chaque instruction du langage est conçue pour être compilée en un nombre d'instructions machine assez prévisible en termes d'occupation mémoire et de charge de calcul. Il propose un éventail de types entiers et flottants conçus pour pouvoir correspondre directement aux types supportés par le processeur. Il fait en outre un usage intensif des calculs d'adresse mémoire avec la notion de pointeur. Il a une notion de type composé, mais ne propose aucune opération qui traite directement des objets de plus haut niveau (fichier informatique, chaîne de caractères, liste…). Ces types plus évolués doivent être traités en manipulant des pointeurs et des types composés. De même, le langage ne propose pas en standard la gestion de la programmation orientée objet, ni de système de gestion d'exceptions. Il existe des fonctions standards pour gérer les entrées-sorties et les chaînes de caractères, mais contrairement à d'autres langages, aucun opérateur spécifique pour améliorer l'ergonomie. Ceci rend aisé le remplacement des fonctions standards par des fonctions spécifiquement conçues pour un programme donné.

Ces caractéristiques en font un langage privilégié quand on cherche à maîtriser les ressources utilisées, le langage machine et les données binaires générées par les compilateurs étant relativement prévisibles. Ce langage est donc extrêmement utilisé dans des domaines comme la programmation embarquée sur microcontrôleurs, les calculs intensifs, l'écriture de systèmes d'exploitation et les modules où la rapidité de traitement est importante. Il constitue une bonne alternative au langage d'assemblage dans ces domaines, avec les avantages d'une syntaxe plus expressive et de la portabilité du code source. Le langage C a été inventé pour écrire le système d'exploitation UNIX, et reste utilisé pour la programmation système. Ainsi le noyau de grands systèmes d'exploitation comme Windows et Linux sont développés en grande partie en C.

En contrepartie, la mise au point de programmes en C, surtout s'ils utilisent des structures de données complexes, est plus difficile qu'avec des langages de plus haut niveau. En effet, dans un souci de performance, le langage C impose à l'utilisateur de programmer certains traitements (libération de la mémoire, vérification de la validité des indices sur les tableaux…) qui sont pris en charge automatiquement dans les langages de haut niveau.

Dépouillé des commodités apportées par sa bibliothèque standard, C est un langage simple, et son compilateur l'est également. Cela se ressent au niveau du temps de développement d'un compilateur C pour une nouvelle architecture de processeur : Kernighan et Ritchie estimaient qu'il pouvait être développé en deux mois car « on s'apercevra que les 80 % du code d'un nouveau compilateur sont identiques à ceux des codes des autres compilateurs existant déjà. »[5].

Qualités et défauts

C'est un des langages les plus utilisés car :

  • il existe depuis le début des années 1970, il est fondé sur un standard ouvert, de nombreux informaticiens le connaissent, des compilateurs et bibliothèques logicielles existent sur la plupart des architectures ;
  • il a influencé de nombreux langages plus récents dont C++, Java et PHP ; sa syntaxe en particulier est largement reprise ;
  • il met en œuvre un nombre restreint de concepts, ce qui facilite sa maîtrise et l'écriture de compilateurs simples et rapides ;
  • il ne spécifie pas rigidement le comportement du fichier exécutable produit, ce qui aide à tirer parti des capacités propres à chaque ordinateur ;
  • il permet l'écriture de logiciels qui n'ont besoin d'aucun support à l'exécution (ni bibliothèque logicielle ni machine virtuelle), au comportement prévisible en temps d'exécution comme en consommation de mémoire vive, comme des noyaux de système d'exploitation et des logiciels embarqués.

Ses principaux inconvénients sont :

  • la première version du langage (K&R C) offre particulièrement peu de vérifications lors de la compilation, et il n'offre aucune vérification pendant l'exécution, ce qui fait que des erreurs qui pourraient être automatiquement détectées lors du développement ne le sont que plus tard, souvent au prix d'un plantage du logiciel ;
  • son approche de la modularité est restée au niveau de ce qui se faisait au début des années 1970, et a été largement dépassée depuis par d'autres langages :
    • il ne facilite pas le développement de concepts informatiques plus récents comme la programmation orientée objet ou la gestion d'exceptions qui exigent un effort de rigueur supplémentaire de la part du développeur ;
    • il ne propose qu’un support très limité de la généricité (malgré l’introduction en C11 de la construction _Generic) ;
  • il est difficile d'écrire des programmes portables car le comportement exact des exécutables dépend de l'ordinateur cible ;
  • le support de l'allocation de mémoire et des chaînes de caractères est minimaliste, ce qui oblige les programmeurs à s'occuper de détails fastidieux et sources de bugs ; il n'y a notamment pas de ramasse-miettes standard ;
  • un petit manque d'attention du développeur peut facilement causer des bugs comme le dépassement de tampon qui constituent une faille de sécurité informatique exploitable par des logiciels malveillants ;
  • plusieurs genres d'erreurs ne peuvent être détectées automatiquement qu'à l'aide d'outils supplémentaires et non standardisés, comme lint et Valgrind.

Aperçu de la syntaxe

Hello world

Le programme Hello world est proposé en exemple en 1978 dans The C Programming Language de Brian W. Kernighan et Dennis M. Ritchie. Créer un programme affichant Hello World est depuis devenu l'exemple de référence pour présenter les bases d'un nouveau langage. Voici l'exemple original :

#include <stdio.h>

main()
{
    printf("hello, world\n");
}

Le même programme, conforme à la norme ISO et suivant les bonnes pratiques contemporaines :

#include <stdio.h>

int main(void)
{
    printf("hello, world\n");
    return 0;
}
  • #include <stdio.h> inclut l'en-tête standard <stdio.h>, contenant les déclarations des fonctions d'entrée-sortie de la bibliothèque standard de C, et notamment de la fonction printf utilisée ici.
  • main est le nom de la fonction principale, aussi appelée point d'entrée (en) du programme.
  • int est le type renvoyé par la fonction main. Le type int est implicite en K&R C et en C89, mais plus en C99. Il était couramment omis du temps où l'exemple de Kernighan et Ritchie a été écrit.
  • Les parenthèses après main indiquent que main est une fonction.
  • Le mot clé void entre parenthèses signifie que la fonction ne prend aucun paramètre.
  • Les accolades { et } entourent les instructions constituant le corps de la fonction main.
  • printf est une fonction d'écriture dans la sortie standard (la console par défaut).
  • Le caractère " délimite une chaîne de caractères ; "hello, world\n" dans ce cas.
  • Les caractères \n sont une séquence d'échappement représentant le saut de ligne.
  • Un point-virgule ; termine l'instruction.
  • L'instruction return 0; indique que la fonction main retourne la valeur 0.

Brièveté de la syntaxe

La syntaxe de C a été conçue pour être brève. Historiquement, elle a souvent été comparée à celle de Pascal, langage impératif également créé dans les années 1970. Voici un exemple avec une fonction factorielle :

/* En C (norme ISO) */
int factorielle(int n)
{
        if (n > 1) return n * factorielle(n - 1);
        else return 1;
}
{ En Pascal }
function factorielle(n: integer) : integer
begin
        if n > 1 then factorielle := n * factorielle(n - 1)
        else factorielle := 1
end.

Là où Pascal utilise des mots clés comme function, begin, end, then, et integer, C utilise des parenthèses, des accolades, et l'abréviation int.

Langage d'expressions

La brièveté de C ne repose pas que sur la syntaxe. Le grand nombre d'opérateurs disponibles, le fait que la plupart des instructions contiennent une expression, que les expressions produisent presque toujours une valeur, et que les instructions de test se contentent de comparer avec zéro la valeur de l'expression testée, concourent à la brièveté du code source.

Voici l'exemple de fonction de copie de chaîne de caractères donné dans The C Programming Language, 2nd edition, p. 106 :

void strcpy(char *s, char *t)
{
    while (*s++ = *t++)
        ;
}

Le principe est de copier les caractères jusqu'à avoir copié le caractère nul, qui marque par convention la fin d'une chaîne en C. La boucle while utilise un style d'écriture classique en C, qui a contribué à lui donner une réputation de langage peu lisible. L'expression *s++ = *t++ contient : deux déréférencements de pointeur ; deux incrémentations de pointeur ; une affectation ; et la valeur affectée est comparée avec zéro par le while. Cette boucle n'a pas de corps, car toutes les opérations sont effectuées dans l'expression de test du while. On considère qu'il faut maîtriser ce genre de notation pour maîtriser C[6].

Des sources à l'exécutable

Sources

Édition d'un fichier source C avec l'éditeur de texte Jed.

Article détaillé : Code source.

Un programme écrit en C est généralement réparti en plusieurs fichiers sources, qui peuvent être compilés chacun séparément.

Les fichiers sources C sont des fichiers texte, généralement dans le codage de caractères du système hôte. Ils peuvent être écrits avec un simple éditeur de texte. Il existe de nombreux éditeurs, voire des environnements de développement intégrés (IDE), qui ont des fonctions spécifiques pour supporter l'écriture de sources en C.

L'usage est de donner les extensions de nom de fichier .c et .h aux fichiers source C. Les fichiers .h sont appelés fichiers d'en-tête, de l'anglais header. Ils sont conçus pour être inclus au début de chaque unité de compilation.

  • Les fichiers .c contiennent les instructions et variables globales. Ils contiennent aussi les déclarations des fonctions, variables, types, etc., qui ne sont pas exportés.
  • Les fichiers .h contiennent uniquement des déclarations.

Lorsqu'un fichier .c ou .h utilise un identificateur déclaré dans un autre fichier .h, alors il inclut ce dernier fichier. Le principe généralement appliqué consiste à écrire un fichier .h pour chaque fichier .c, et à déclarer dans le fichier .h tout ce qui est exporté par le fichier .c.

La génération d'un exécutable, à partir des fichiers sources se fait en plusieurs étapes, qui sont souvent automatisées à l'aide d'outils comme make, SCons, ou bien des outils spécifiques à l'environnement de développement intégré (IDE) utilisé. Les étapes menant des sources au fichier exécutable sont au nombre de quatre : précompilation, compilation, assemblage, édition des liens. Lorsqu'un projet est compilé, seuls les fichiers .c font partie de la liste des fichiers à compiler. Les fichiers .h étant inclus lors de la précompilation.

Précompilation

Article détaillé : Préprocesseur.

Par défaut, le préprocesseur transmet le source tel quel au compilateur, si ce n'est qu'il supprime les commentaires et les espaces superflus. Toutefois, lorsqu'il rencontre une instruction qui lui est destinée (commençant par le croisillon #), le préprocesseur effectue des opérations telles que :

  • inclusion d'un autre fichier
  • remplacement d'un mot
  • blanchiment de lignes entières

En incluant récursivement tous les fichiers d'en-tête inclus par un fichier .c, le préprocesseur crée une entité de compilation pour le compilateur.

Compilation

Article détaillé : Compilateur.

La phase de compilation consiste généralement en la génération du code assembleur. C'est la phase la plus intensive en traitements. Elle est accomplie par le compilateur proprement dit. Pour chaque unité de compilation, on obtient un fichier en langage d'assemblage.

Cette étape peut être divisée en sous-étapes :

  • l'analyse lexicale, qui est la reconnaissance des mots clé du langage
  • l'analyse syntaxique, qui analyse la structure du programme et sa conformité avec la norme
  • l'optimisation de code
  • l'écriture d'un code isomorphe à celui de l'assembleur (et parfois du code assembleur lui-même quand cela est demandé en option du compilateur)

Par abus de langage, on appelle compilation toute la phase de génération d'un fichier exécutable à partir des fichiers sources. Mais c'est seulement une des étapes menant à la création d'un exécutable.

Certains compilateurs C fonctionnent à ce niveau en deux phases, la première générant un fichier compilé dans un langage intermédiaire destiné à une machine virtuelle idéale (voir P-Code) portable d'une plate-forme à l'autre, la seconde convertissant le langage intermédiaire en langage d'assemblage dépendant du processeur utilisé sur la plate-forme cible.

D'autres compilateurs C permettent de ne pas générer de langage d'assemblage, mais seulement le fichier compilé en langage intermédiaire, qui sera interprété ou compilé automatiquement en code natif à l'exécution sur la machine cible (par une machine virtuelle qui sera liée au programme final).

Assemblage

Article détaillé : Assemblage (informatique).

Cette étape consiste en la génération d'un fichier objet pour chaque fichier de code assembleur. Ces fichiers objet sont en langage machine. C'est un format binaire, dépendant du processeur. Les fichiers objet sont généralement d’extension « .o » sous Unix, et « .obj » avec les outils de développement pour plates-formes Microsoft, Intel, Digital, IBM : DOS, Windows, VMS, CP/M… où les extensions se présentent usuellement sous la forme de trois caractères.

Cette phase est parfois regroupée avec la précédente par établissement d'un flux de données interne sans passer par des fichiers en langage intermédiaire ou langage d'assemblage. Dans ce cas, le compilateur génère directement un fichier objet binaire.

Pour les compilateurs qui génèrent du code intermédiaire, cette phase d'assemblage peut aussi être totalement supprimée : c'est la machine virtuelle, liée au programme final, ou utilisée séparément comme chargeur du programme à exécuter sur une machine virtuelle partagée et optimisée spécialement sur la machine hôte, qui interprétera ou compilera ce langage en code machine natif directement sur la machine hôte. Dans ce cas, la machine virtuelle qui interprète le langage intermédiaire ou le compile en code natif optimisé pour la machine hôte, peut être un composant du système d'exploitation ou une bibliothèque partagée installée sur celui-ci, et cette machine virtuelle ne sera même pas incluse dans le programme final livrable.

Édition des liens

Article détaillé : Édition de liens.

L'édition des liens est la dernière étape, et a pour but de réunir tous les éléments d'un programme. Les différents fichiers objet sont alors réunis, ainsi que les bibliothèques statiques, pour ne produire qu'un fichier exécutable.

Le but de l'édition de liens est de sélectionner les éléments de code utiles présents dans un ensemble de codes compilés et de bibliothèques, et de résoudre les références mutuelles entre ces différents éléments afin de permettre à ceux-ci de se référencer directement à l'exécution du programme. L'édition des liens échoue si des éléments de code référencés manquent.

Éléments du langage

Éléments lexicaux

Le jeu de caractères ASCII suffit pour écrire en C. Il est même possible, mais inusité, de se restreindre au jeu de caractères invariants de la norme ISO 646, en utilisant des séquences d'échappement appelées trigraphe. En général, les sources C sont écrits avec le jeu de caractères du système hôte. Il est toutefois possible que le jeu de caractères d'exécution ne soit pas celui du source.

Le C est sensible à la casse. Les caractères blancs (espace, tabulation, fin de ligne) peuvent être librement utilisés pour la mise en page, car ils sont équivalent à un seul espace dans la plupart des cas.

Fonctions

Les fonctions en C sont des blocs d'instructions, recevant un ou plusieurs arguments et pouvant retourner une valeur. Si une fonction ne retourne aucune valeur, le mot-clé void est utilisé. Une fonction peut également ne recevoir aucun argument. Le mot-clé void est également utilisé dans ce cas.

// Fonction ne retournant aucune valeur
void afficher(int a)
{
    printf("%d", a);
}

// Fonction retournant un entier
int somme(int a, int b)
{
    return a + b;
}

// Fonction sans aucun argument
int saisir(void)
{
    int a;
    scanf("%d", &a);
    return a;
}

Prototype

Un prototype consiste à déclarer une fonction sans les instructions qui la compose. Un prototype se termine par un point-virgule. Leur intérêt est de pouvoir utiliser une fonction avant de devoir écrire ses instructions.

// Prototype de saisir()
int saisir();

// Fonction utilisant saisir()
int somme()
{
    int a = saisir(), b = saisir();
    return a + b;
}

// Définition de saisir()
int saisir()
{
    int a;
    scanf("%d", &a);
    return a;
}

Dans ce cas là, si on oublie d'écrire le prototype, il y aura une erreur à la compilation. Généralement, tous les prototypes sont écris dans des fichiers .h, et les fonctions sont définies dans un fichier .c classique.

Mots clés

Le C89 compte 32 mots clés, dont 5 qui n'existaient pas en K&R C, et qui sont par ordre alphabétique :

auto, break, case, char, const (C89), continue, default, do, double, else, enum (C89), extern, float, for, goto, if, int, long, register, return, short, signed (C89), sizeof, static, struct, switch, typedef, union, unsigned, void (C89), volatile (C89), while.

Ce sont des termes réservés qui ne doivent pas être utilisés autrement.

La révision C99[7] en ajoute cinq :

_Bool, _Complex, _Imaginary, inline, restrict.

Ces nouveaux mots-clés commencent par une majuscule préfixée d’un underscore afin de maximiser la compatibilité avec les codes existants. Des en-têtes de la bibliothèque standard fournissent les alias bool (<stdbool.h>), complex et imaginary (<complex.h>),

La dernière révision, C11[8], introduit encore sept nouveaux mots-clés avec les mêmes conventions :

_Alignas, _Alignof, _Atomic, _Generic, _Noreturn, _Static_assert, _Thread_local.

Les en-têtes standards <stdalign.h>, <stdnoreturn.h>, <assert.h> et <threads.h> fournissent respectivement les alias alignas et alignof, noreturn, static_assert, et thread_local.

Instructions du préprocesseur

Le préprocesseur du langage C offre les directives suivantes :

#include, #define, #pragma (C89), #if, #ifdef, #ifndef, #elif (C89), #else, #endif, #undef, #line, #error.

Types

Le langage C comprend de nombreux types de nombres entiers, occupant plus ou moins de bits. La taille des types n'est que partiellement standardisée : le standard fixe uniquement une taille minimale et une magnitude minimale. Les magnitudes minimales sont compatibles avec d'autres représentations binaires que le complément à deux, bien que cette représentation soit presque toujours utilisée en pratique. Cette souplesse permet au langage d'être efficacement adapté à des processeurs très variés, mais elle complique la portabilité des programmes écrits en C.

Chaque type entier a une forme « signée » pouvant représenter des nombres négatifs et positifs, et une forme « non signée » ne pouvant représenter que des nombres naturels. Le type char, généralement utilisé pour représenter un caractère, est un type entier comme les autres, si ce n'est que, selon l'implémentation, il équivaut à signed char ou à unsigned char.

Types entiers, en ordre croissant
Type | Taille | Magnitude signed (magnitude minimale exigée par le standard[9]) | Magnitude unsigned (magnitude minimale exigée par le standard[9])
char, unsigned char, signed char (C89) | ≥ 8 bits | -127 à 127 | 0 à 255 (0xFF en hexadécimal noté avec le préfixe 0x de la syntaxe de C)
short (identique à signed short), unsigned short | ≥ 16 bits | -32 767 à +32 767 | 0 à 65 535 (0xFFFF)
int (identique à signed int), unsigned int | ≥ 16 bits (taille d'un mot machine) | -32 767 à +32 767 | 0 à 65 535 (0xFFFF)
long (identique à signed long), unsigned long | ≥ 32 bits | -2 147 483 647 à +2 147 483 647 | 0 à 4 294 967 295 (0xFFFFFFFF)
long long (identique à signed long long), unsigned long long (C99) | ≥ 64 bits | -9 223 372 036 854 775 807 à +9 223 372 036 854 775 807 | 0 à 18 446 744 073 709 551 615 (0xFFFFFFFFFFFFFFF)

Le type enum est un type énuméré.

Il existe des types de nombres à virgule flottante, de précision, donc de longueur en bits, variable ; en ordre croissant :

Types décimaux, en ordre croissant
Type | Précision | Magnitude
float | ≥ 6 chiffres décimaux | environ 10-37 à 10+37
double | ≥ 10 chiffres décimaux | environ 10-37 à 10+37
long double | ≥ 10 chiffres décimaux | environ 10-37 à 10+37
long double (C89) | ≥ 10 chiffres décimaux |

C99 a ajouté float complex, double complex et long double complex, représentant les nombres complexes associés.

Types élaborés :

  • struct, union, * pour les pointeurs ;
  • [ … ] pour les tableaux ;
  • ( … ) pour les fonctions.

Les versions du langage antérieures à C99 ne proposent pas de type booléen, mais il est possible d'en définir un :

enum boolean {false, true};
typedef enum boolean bool;

ou, en version condensée :

typedef enum boolean {false, true} bool;

C99 ajoute le type _Bool.

Le type void *

Le type void * est un pointeur sur un type indéterminé. Il permet par exemple à une fonction de retourner n'importe quel type de données. Il faut néanmoins penser à convertir le pointeur void * en pointeur typé (opération appelées également type casting) pour que les opérations sur ce pointeur soient justes. Il est par exemple utilisé par la fonction malloc, pour retourner l'adresse du bloc adressé.

Structures

Les structures sont l'ancêtre des classes. Il s'agit de variables (champs) regroupées dans un même bloc. Pour définir une structure, il faut utiliser le mot-clé struct suivit du nom de la structure. Les variables doivent ensuite être déclarées dans un modules, qui se finit par un point-virgule.

// Définition de la structure personne
struct Personne
{
    int age;
    char * nom;
};

Pour accéder aux champs d'une classe, il faut utiliser l'opérateur . suivit du nom du champs. Pour déclarer une structure la même syntaxe qu'avec les variables est utilisée.

int main()
{
    struct Personne p;
    p.nom = "Albert";
    p.age = 46;
}

Les fonctions peuvent recevoir des pointeurs vers des structures. Ils fonctionnent avec la même syntaxe que les pointeurs classiques. Néanmoins, l'opérateur -> doit être utilisé sur le pointeur pour accéder aux champs de la structure. Il est également possible de déréférencer le pointeur pour ne pas utiliser cet opérateur, est toujours utiliser l'opérateur .

void anniversaire(struct Personne  * p)
{
    p->age++;
    printf("Joyeux anniversaire %s !", (*p).nom);
}

int main()
{
    struct Personne p;
    p.nom = "Albert";
    p.age = 46;
    anniversaire(&p);
}

Commentaire

Dans les versions de C antérieures à C99, les commentaires devaient commencer par une barre oblique et un astérisque (« /* ») et se terminer par un astérisque et une barre oblique. Tout ce qui est compris entre ces symboles est du commentaire, saut de ligne compris :

/* Ceci est un commentaire
   sur deux lignes
   ou plus */

La norme C99 a repris de C++ les commentaires de fin de ligne, introduits par deux barres obliques et se terminant avec la ligne :

// Commentaire jusqu'à la fin de la ligne

Structures de contrôle

La syntaxe des différentes structures de contrôle existantes en C est largement reprise dans plusieurs autres langages, comme le C++ bien sûr, mais également Java, C#, PHP ou encore JavaScript.

Les trois grands types de structures sont présents :

  • les tests (également appelés branchements conditionnels) avec :
    • if (expression) instruction
    • if (expression) instruction else instruction
    • switch (expression) instruction, avec case et default dans l'instruction
  • les boucles avec :
    • while (expression) instruction
    • for (expression_optionnelle ; expression_optionnelle ; expression_optionnelle) instruction
    • do instruction while (expression)
  • les sauts (branchements inconditionnels) :
    • break
    • continue
    • return expression_optionnelle
    • goto étiquette

Comportements ambigus

La norme du langage C laisse la définition exacte du comportement de plusieurs opérations au choix du concepteur du compilateur. Ces comportements sont donc définis par l'implémentation. Cette propriété de C permet au compilateur d'utiliser directement les instructions proposées par le processeur, donc de compiler des programmes exécutables courts et efficaces. En contrepartie, c'est parfois la cause de bugs de portabilité des codes source écrits en C. Prenons pour exemple[10] la division entière d'un nombre négatif : -5 / 3. Alors que Fortran, Pascal et Ada spécifient un résultat de -1, et que Modula-3 spécifie un résultat de -2, C[11] garantit simplement que la valeur absolue du reste est strictement inférieure à la valeur absolue du diviseur[12]. La seule garantie dans cet exemple est donc que le résultat sera compris entre -2 et -1.

Pour le programmeur et l'efficacité de C, le plus important est sans doute le fait que les tailles des types de données de base ne doivent respecter que des garanties minimales. Ainsi, le type int correspondant au mot machine peut avoir une taille de 16 bits sur un processeur 16 bits et une taille de 64 bits sur un processeur 64 bits.

Outre les comportements laissés au choix de l'implémentation, des constructions syntaxiquement valables ont un comportement lors de l'exécution complètement indéfini. En plus de la classique division par zéro, on peut signaler l'affectation multiple d'une variable dans la même expression, avec l'exemple[13] :

 i = i++; /* Comportement indéfini. */

Les meilleurs compilateurs décèlent certaines constructions problématiques et peuvent les signaler, mais aucun ne prétend à l'exhaustivité.

Bibliothèques logicielles

La bibliothèque standard

Article détaillé : Bibliothèque standard du C.

La bibliothèque standard normalisée, disponible avec toutes les implémentations, présente la simplicité liée à un langage bas-niveau. Voici une liste de quelques en-têtes déclarant des types et fonctions de la bibliothèque standard :

  • <assert.h> : pour un diagnostic de conception lors de l'exécution (assert)
  • <ctype.h> : tests et classification des caractères (isalnum, tolower)
  • <errno.h> : gestion minimale des erreurs (déclaration de la variable errno)
  • <math.h> : fonctions mathématiques de base (sqrt, cos) ; nombreux ajouts en C99
  • <signal.h> : gestion des signaux (signal et raise)
  • <stddef.h> : définitions générales (déclaration de la constante NULL)
  • <stdio.h> : pour les entrées/sorties de base (printf, scanf)
  • <stdlib.h> : fonctions générales (malloc, rand)
  • <string.h> : manipulation des chaînes de caractères (strcmp, strlen)
  • <time.h> : manipulation du temps (time, ctime)

La bibliothèque standard normalisée n'offre aucun support de l'interface graphique, du réseau, des entrées/sorties sur port série ou parallèle, des systèmes temps réel, des processus, ou encore de la gestion avancée des erreurs (comme avec des exceptions structurées). Cela pourrait restreindre d'autant la portabilité pratique des programmes qui ont besoin de faire appel à certaines de ces fonctionnalités, sans l'existence de très nombreuses bibliothèques portables et palliant ce manque ; dans le monde UNIX, ce besoin a aussi fait émerger une autre norme, POSIX.1.

Les bibliothèques externes

Le langage C étant un des langages les plus utilisés en programmation, de nombreuses bibliothèques ont été créées pour être utilisées avec le C. Fréquemment, lors de l'invention d'un format de données, une bibliothèque ou un logiciel de référence en C existe pour manipuler le format. C'est le cas pour libjpeg, libpng, Expat, les décodeur de référence MPEG, libsocket etc.

Exemples

Voici quelques exemples présentant très succinctement quelques propriétés du C. Pour plus d'information, voir le WikiLivre "Programmation C".

Allocation mémoire

La structure int_list représente un élément d'une liste chaînée, contenant des données de type int. Les deux fonctions qui suivent (insert_next et remove_next) servent à ajouter et supprimer un élément de la liste.

/* La gestion de la mémoire n'est pas intégrée au langage
   mais assurée par des fonctions de la bibliothèque standard. */
#include <stdlib.h>

struct int_list {
    struct int_list *next; /* pointeur sur l'élément suivant */
    int value;             /* valeur de l'élément */
};

/*
 * Ajouter un élément à la suite d'un autre.
 * node : élément après lequel ajouter le nouveau
 * value : valeur de l'élément à ajouter
 * Retourne : adresse de l'élément ajouté, ou NULL en cas d'erreur.
 */
struct int_list *insert_next(struct int_list *node, int value) {
    /* Allocation de la mémoire pour un nouvel élément. */
    struct int_list *const new_next = malloc(sizeof *new_next);

    /* Si l'allocation a réussi, alors insérer new_next entre node
       et node->next. */
    if (new_next) {
        new_next->next = node->next;
        node->next = new_next;
        new_next->value = value;
    }

    return new_next;
}

/*
 * Supprimer l'élément suivant un autre.
 * node : élément dont le suivant est supprimé
 * Attention : comportement indéterminé s'il n'y pas d'élément suivant !
 */
void remove_next(struct int_list *node) {
    struct int_list *const node_to_remove = node->next;

    /* Retire l'élément suivant de la liste. */
    node->next = node->next->next;
    /* Libère la mémoire occupée par l'élément suivant. */
    free(node_to_remove);
}

Dans cet exemple, les deux fonctions essentielles sont malloc et free. La première sert à allouer de la mémoire, le paramètre qu'elle reçoit est le nombre de bytes que l'on désire allouer et elle retourne l'adresse du premier byte qui a été alloué, sinon elle retourne NULL. free sert à libérer la mémoire qui a été allouée par malloc.

Références

  1. ↑ (en) Brian Kernighan et Dennis Ritchie, The C Programming Language [détail des éditions], pp. 260,261
  2. ↑ (en), Samuel P. Harbison III, Guy L. Steel Jr., A Reference Manual, fifth edition, Upper Saddle River, Prentice Hall, 2002, p. 4, (ISBN 0-13-089592-X)
  3. ↑ (en), Thomas Wolf, The New ISO Standard for C (C9X), 2000
  4. ↑ http://www.iso.org/iso/fr/iso_catalogue/catalogue_tc/catalogue_detail.htm?csnumber=57853
  5. ↑ Brian Kernighan et Dennis Ritchie (trad. Thierry Buffenoir), Le langage C [« The C Programming Language »], Paris, Masson,‎ 1983, 1e éd., 218 p. [détail des éditions] (ISBN 2-225-80068-5), p. 4
  6. ↑ (en) Brian Kernighan et Dennis Ritchie, The C Programming Language, Prentice Hall,‎ 1988, 2e éd., 272 p. [détail des éditions] (ISBN 0-13-110362-8), p. 106.
  7. ↑ « ISO/IEC 9899:TC3, Section 6.4.1: Keywords », International Organization for Standardization JTC1/SC22/WG14,‎ 2007-09-07
  8. ↑ « ISO/IEC 9899:201x, Section 6.4.1: Keywords », International Organization for Standardization JTC1/SC22/WG14,‎ 2011-04-12
  9. ↑ a et b « Sizes of integer types », ISO-IEC 9899, 5.2.4.2.1
  10. ↑ donné dans Computer Architecture, a Quantitative Approach, 2e édition, de David A. Patterson et John L. Hennessy, Appendix A-12
  11. ↑ La révision en 1999 de la norme spécifie explicitement que le résultat soit -1 [6.5.5 alinéa 6].
  12. ↑ (en) Brian Kernighan et Dennis Ritchie, The C Programming Language, Prentice Hall,‎ 1988, 2e éd., 272 p. [détail des éditions] (ISBN 0-13-110362-8), p. 205
  13. ↑ (en) comp.lang.c FAQ list · Question 3.3

Voir aussi

Sur les autres projets Wikimedia :

· La programmation en C, sur Wikiversity

· Le langage C, sur Wikibooks

Articles connexes

  • Bibliothèque standard du C
  • Alignement de données
  • International Obfuscated C Code Contest

Quelques programmes célèbres écrits en C

  • UNIX
  • La suite de compilateurs GNU Compiler Collection (GCC)
  • Le noyau Linux
  • Le noyau de Microsoft Windows
  • GNOME

Bibliographie

  • (en) Brian Kernighan et Dennis Ritchie, The C Programming Language [détail des éditions]
  • The international standardization working group for the programming language C ,
    • (en) ISO/IEC 9899:TC2 WG14/N1124, « Committee Draft », 6 mai 2005 [lire en ligne]

Le dernier brouillon de la norme ISO C99 incluant le Technical Corrigendum 2

    • (en) ISO/IEC 9899:TC3 WG14/N1256, « Committee Draft », 7 septembre 2007 [lire en ligne]

Le dernier brouillon de la norme ISO C99 incluant le Technical Corrigendum 3

  • (en) Ivor Horton , Beginning C, Apress, 2006, (ISBN 978-1-59059-735-4)
  • Jean-Michel Léry, Le langage C, Pearson Education, 2005, (ISBN 978-2-7440-7086-0)
  • Achille Braquelaire, Méthodologie de la programmation en C, Dunod,‎ 2005 [détail de l’édition], (ISBN 978-2-10-049018-9)
  • Claude Delannoy, Programmer en langage C - Cours et exercices corrigés, Eyrolles, 11e tirage - 2006, (ISBN 978-2-212-11072-2)
  • Mathieu Nebra, Apprenez à programmer en C - 2e édition, Édition Site du Zéro - 2009, (ISBN 979-1090085008)
  • Éric Berthomier et Daniel Schang, Le C en 20 heures, Framasoft, coll. « Framabook » (no 6),‎ 2013, 3e éd. (1re éd. 2010), 196 p. (ISBN 978-2-9539187-7-9, présentation en ligne)

Liens externes

  • (fr) Catégorie Langage C de l’annuaire dmoz
  • Coding Programmer Page / C Library Reference and Examples (english)

v · d · m Langages de programmation
Orientés objet | ActionScript · AppleScript · C++ · C# · CoffeeScript · D · Delphi · Eiffel · Groovy · Java · JavaScript · Lisaac · Logo · Objective-C · PHP · Python · Ruby · Scala · Simula · Smalltalk · Visual Basic
Impératifs | Algol · APL · ASP · Assembleur · BASIC · BCPL · Shell Unix · C · COBOL · Natural · Forth · Fortran · Limbo · Lua · Modula-2 · NQC · NXC · OPL · Pascal · Perl · PHP · Rust · PL/I · Tcl · Windows PowerShell · WLangage
Fonctionnels | Haskell · Lisp / Common Lisp · ML / OCaml / F# · Opa · Scheme · XSLT
Déclaratifs | Clips · Prolog
Concurrents | Ada · Erlang
Conception · Codage · Tests · Optimisations

·


Note sur le C

Les règles d'or de la programmation

  • I. Introduction
  • II. Efficacité
  • III. Fiabilité
  • IV. Robustesse
  • V. Interface
  • VI. Portabilité
  • VII. Maintenabilité
  • VIII. Travail en commun, versionning et sauvegardes
  • IX. Conclusion
  • X. Remerciements

Cet article recueille des conseils et les bonnes pratiques à adopter en programmation.

I. Introduction

Ce document n'a pas pour but d'être une bible de la meilleure méthode de programmation. Il m'a été inspiré non seulement par les différents stagiaires dont je me suis occupé et par les morceaux de code que j'ai pu voir, postés ici et là, mais aussi par ma propre expérience professionnelle. La question que je me suis souvent posée étant la suivante : comment faire pour écrire le meilleur programme possible ?

Au bout de quelque temps, je me suis aperçu que je ne pourrai pas atteindre ce but : en effet, répondre à cette question implique d'atteindre un absolu, sans tenir aucun compte de notre environnement de travail : comment, par exemple, écrire le meilleur programme possible alors que notre responsable ou notre client vient nous parler tous les jours de délai de livraison ?

J'ai donc modifié cette question en une autre : comment faire pour devenir un meilleur développeur ? Nous sommes tous perfectibles et donc, nous devons nous améliorer.

Quel que soit votre environnement de développement, langage, société dans laquelle vous travaillez, votre programme et vous-même devez répondre aux règles décrites dans cet article.

II. Efficacité

Vous, comme votre programme, devez être efficace. C'est-à-dire arriver à un maximum de résultat pour un minimum d'effort. Pour ceci, non seulement il existe des méthodes de programmation dont vous pouvez vous inspirer mais vous devez toujours garder en tête, à la fois le souci de votre propre efficacité mais aussi celui de votre programme.

Votre propre efficacité commence dès la conception en répondant aux besoins de l'utilisateur et uniquement aux besoins de l'utilisateur : il est inutile de développer une fonction compliquée quand il en veut une simple ou même de développer une fonctionnalité non demandée mais qui vous paraîtrait indispensable. Si vous avez des doutes, vous pouvez toujours lui poser des questions complémentaires.

Ne confondez pas l'efficacité de votre programme avec sa rapidité : il est inutile de mettre en œuvre un algorithme plus compliqué si vous passez plus de temps à l'écrire. Si jamais lors de vos tests vous vous rendez compte qu'il y a un problème de temps de réponse, il sera toujours temps d'envisager une autre manière de faire. En résumé, vous devez être rapide à écrire votre programme quitte à réutiliser des morceaux de code que vous avez utilisés dans un autre programme.

Il s'agit d'écrire le moins de code possible. Votre programme devra être clair et sans redondance de code.

Parmi les méthodes de programmation que vous pouvez utiliser, une d'entre elles me semble obligatoire : il s'agit de découper les tâches à réaliser en tâches de plus en plus petites. Par exemple, pour construire une maison, vous aurez une tâche qui va s'appeler contruire_maison et pour construire cette maison vous en aurez d'autres qui pourraient s'appeler construire_fondations, construire_murs, construire_toiture et enfin remplir_maison. Il devient évident que l'on va continuer à découper chacune de ces quatre tâches par d'autres qui le seront encore à leur tour. Une telle méthode présente l'avantage de ne rien oublier et de laisser un programme clair, sous réserve que vous le commentiez un minimum et que vous utilisiez des noms convenables : clair, précis et concis.

Une autre méthode, importante également surtout si vous avez une grosse application à développer est de la développer par module. Vous pourrez alors sans doute livrer au client un premier module dont il pourra se servir pendant que vous écrirez les modules suivants. Par exemple, il pourra commencer à remplir la base de données pendant que vous travaillerez sur la suite. Ce qui vous permettra d'avoir d'un jeu d'essais plus proche du réel. De plus, un module bien conçu pourra être réutilisé dans d'autres projets.

Pour gagner en efficacité, il devient utile de copier-coller du code. En effet, il se peut que certaines fonctionnalités aient déjà été développées. Si votre code est bien conçu, il suffira juste de copier la fonction ou la classe pour l'intégrer dans votre nouveau projet.
Si c'est un code venant d'Internet, il est extrêmement important de le comprendre avant de l'intégrer. Si vous sautez cette étape, vous allez entraîner des failles de sécurité et de fiabilité dans l'application.

Le copier-coller peut être une bonne pratique pour ne pas réécrire un code. Toutefois, si vous effectuez un copier-coller de votre code dans votre propre projet (création d'un duplicata de code), ceci est une mauvaise pratique. En effet, si ce code contient un bogue, vous allez possiblement oublier de le corriger aux deux endroits. De plus, lors de l'utilisation de la copie, il vous faudra peut-être modifier le nom des variables et certaines peuvent être oubliées, créant ainsi de nouveaux bogues.
Ainsi, à la place d'une copie, il est préférable de créer une fonction et de l'appeler deux fois.

III. Fiabilité

Votre programme doit être fiable, c'est-à-dire contenir un minimum d'erreurs de programmation. Cela signifie que vous devez tester votre programme. Vérifiez que le programme fait bien ce que vous voulez qu'il fasse et ce, pas seulement avec un seul test. En général, le développeur a tendance à tester les valeurs qui l'arrangent, il vous faut donc un jeu d'essais complet à lancer à chaque modification que vous apportez à votre application. De plus, ne laissez pas le programme se planter lamentablement : gérez les erreurs proprement. Si une fonction retourne un code d'erreur ou une exception (pour les langages les supportant), vous devez le prendre en compte. Une fois que vous vous apercevez d'un quelconque problème, réfléchissez à ce qui doit être fait selon la gravité de l'erreur. Si une erreur fatale se produit, sauvegardez les documents de l'utilisateur. Si c'est une erreur bénigne, ne bloquez pas l'application, gérez-la de la manière la plus simple (par exemple, en ignorant les résultats produits lorsqu'une erreur est découverte ou en indiquant un petit message à l'utilisateur) et continuez le travail.

Il vous faut tester votre programme non seulement avec des tests unitaires mais aussi avec des tests d'intégration. Les tests unitaires sont à effectuer à chaque étape importante de l'écriture ou de la modification de votre programme ou module. Les tests d'intégration sont à effectuer à la fin de l'écriture ou de la modification d'un module afin de contrôler qu'il s'intègre bien avec les autres. Le must est d'automatiser ces tests dans la chaîne de développement. Par exemple, on peut mettre en place une machine qui exécutera les tests toutes les nuits en prenant la dernière version du code (grâce à un logiciel de contrôle de version). Comme cela, en arrivant le matin, les développeurs sauront si un problème est apparu et seront alertés au plus tôt. Ainsi, chaque bogue doit avoir son test, afin qu'il ne réapparaisse plus jamais.

Lors de vos tests unitaires vous serez amenés à choisir les valeurs à tester. Il faut toujours en tester plusieurs et les choisir à bon escient : au moins la valeur minimale, la valeur maximale, une valeur hors norme par raison d'anormalité et quelques valeurs convenables qui, dans l'idéal, devront être choisies parmi des valeurs réelles, c'est-à-dire des valeurs en vigueur chez vos utilisateurs.

IV. Robustesse

Votre programme doit être robuste, c'est-à-dire résister aux erreurs de l'utilisateur. Vous devez donc tester chaque zone de saisie au mieux. Par exemple, ne le laissez pas utiliser le 22e élément d'un tableau qui n'en comporte que 20. Dites-le-lui avant. Obligez-le à saisir les zones qui sont réellement obligatoires. D'une manière générale, la programmation défensive est une technique qui donne de bons résultats, car elle vous force à supposer que votre utilisateur va commettre des erreurs. En ce qui concerne vos propres erreurs, des tests unitaires et la programmation par contrat doivent donner des résultats suffisants.

V. Interface

Vous devrez en outre faire un effort particulier au moment de créer votre interface, que ce soit en n-tiers ou non. Votre programme doit faciliter la vie de l'utilisateur, il ne doit pas lui créer des problèmes supplémentaires. Chacune des actions que l'utilisateur doit réaliser doit l'être de manière claire et précise pour lui. S'il se pose trop de questions, il risque de se tromper. Par exemple, s'il met trop de temps pour créer une fiche client, il risque de garder ses dossiers alors qu'un des buts de votre application était peut-être de supprimer ces dossiers pour les remplacer par l'écran. Une autre chose qui va considérablement influer sur le ressenti de l'utilisateur : la documentation que vous lui fournirez. Celle-ci doit être claire et agréable à lire. Elle doit inclure toutes les fonctions de votre application et contenir également tous les messages que votre programme est susceptible d'afficher avec leur signification et éventuellement une codification, car lorsqu'il va avoir un message d'erreur il doit pouvoir se débrouiller seul et ne vous appeler qu'en dernier recours.

VI. Portabilité

Rendre son programme portable est un choix. Toutefois, personne n'est à l'abri du changement, donc quelle que soit la bibliothèque que vous utilisez (multiplate-forme ou pas) n'hésitez pas à prévoir un code architecturé pour faciliter le changement de bibliothèque, si besoin.

Si votre programme doit être portable, n'en faites pas trop. Par exemple, si vous écrivez un site web, il faudra que votre utilisateur puisse l'utiliser avec n'importe quel navigateur et pas seulement avec celui sous lequel vous l'avez écrit et testé. Idem en ce qui concerne les versions Windows : si vous développez sous XP, faites attention que votre programme puisse être utilisé sous les versions suivantes plus exigeantes en ce qui concerne la sécurité. Par contre, il sera sans doute inutile d'assurer une portabilité de Windows vers Linux ou vers les portables et inversement. À quoi donc pourrait servir une géolocalisation de l'utilisateur sur un poste fixe ?

VII. Maintenabilité

Votre programme doit être maintenable, c'est-à-dire non seulement pouvoir être modifié facilement par vous-même dans six mois mais également par quelqu'un d'autre.

Pour ceci, une des toutes premières choses à faire est de mettre en place tout un système de documentation. Documenter implique de commenter vos choix dès la conception dans le dossier d'analyse pour les expliquer jusque dans les moindres fonctions afin que l'on puisse savoir à quoi elles servent. Mais mettez des commentaires utiles : il est inutile d'écrire « incrémentation de i » à côté d'un i++. Utiliser des outils tels que Javadoc ou Doxygen peut vous aider à construire la documentation de votre programme. Il restera à faire celle allant avec le MCD.

Maintenable, cela veut dire aussi : facilement lisible. Vous pouvez utiliser des techniques de nommage comme Camel Case. Cette technique fait que vos variables ressemblent à un dos de chameau du fait de l'alternance de majuscules et de minuscules, comme pour « maSuperbeVariable », d'où son nom. Vous pouvez aussi ajouter un préfixe pour savoir si la variable est une variable membre de la classe (m_) ou encore, un pointeur (p). Dans tous les cas, il faut choisir un nom pour vos variables, fonction, classes, explicite tout en étant court. Vous devez aussi indenter votre programme surtout quand des IDE comme NetBeans le font pour vous.

Une autre manière d'améliorer sa maintenabilité est de pratiquer la refactoristion (refactoring), c'est-à-dire de réécrire le code une fois qu'il respecte au mieux les principes énoncés précédemment. Souvent, un code est écrit dans la rapidité afin de répondre à un besoin et à un délai et celui-ci contiendra donc très certainement des erreurs (il se peut qu'il fonctionne, mais qu'il casse le design de l'application, ou qu'il ne vérifie pas les erreurs retournées par les fonctions, par exemple). La refactorisation permet de réécrire ce ou ces morceaux de code afin de fortifier l'architecture de l'application. Ainsi, vous gagnerez en lisibilité, en fiabilité et en maintenabilité. De plus, comme la refactorisation peut être une opération apportant de nouveaux bogues (car modification du code existant), les tests unitaires automatisés permettront de repérer immédiatement les nouveaux problèmes et de les corriger immédiatement. Ainsi, vous pouvez faire progresser votre application tout en vous assurant de ne pas apporter de problèmes.

Il ne faut pas confondre factorisation et refactorisation. Le premier décrit le fait de créer une fonction afin d'éviter la répétition d'un même code. Le second énonce tout le processus mis en place afin d'améliorer le code, le rendre robuste, lisible, réutilisable, … Il permet entre autres de corriger des erreurs insérées lors de l'écriture de code et de corriger des failles que l'on aurait oubliées. Lors d'une refactorisation, le comportement du programme n'est pas modifié. De plus, lors d'une refactorisation, le code peut être factorisé.

VIII. Travail en commun, versionning et sauvegardes

Vous allez, peut-être, travailler en commun : vous avez besoin de vous assurer que vous n'êtes pas plusieurs à modifier le même code source en même temps.

Vous avez besoin de sauvegarder vos programmes : Il faudra garder plusieurs sauvegardes, au moins une pour les deux dernières versions de votre application voire plus si vous aviez besoin de revenir plus loin encore. En plus d'une sauvegarde à chaque version qui fonctionne, une sauvegarde par jour ou demi-journée est nécessaire au moment où vous développez.

Les outils de versionning sont faits pour ça. Voyez par exemple Subversion, Mercurial, ou Git qui sont des logiciels vous permettant de sauvegarder votre travail sur un serveur distant, mais aussi de garder les informations liées à chaque modification du code et ainsi de pouvoir travailler à plusieurs sur le même projet.

La perte de code n'est pas une fiction. Cela arrive à beaucoup de débutants. C'est pourquoi, même pour un projet personnel, il est conseillé d'utiliser un outil de versionning. Toute entreprise en utilise et maintenant, les universités en installent dans leur parc informatique.

IX. Conclusion

Au cours de cet article, nous avons vu les différentes règles de base que tout programmeur doit suivre dans la réalisation de ses projets. En effet, le tout n'est pas de faire une application qui fonctionne, mais aussi faire en sorte que celle-ci puisse être modifiée aisément par vous ou votre successeur tout en étant le plus efficace. C'est en suivant ces règles simples que vous deviendrez un meilleur programmeur.

Toutefois, l'article s'est limité à parler de la programmation de façon générique. Chaque langage possédera des règles spécifiques qui lui seront propres et qui ne pouvaient donc pas être regroupées ici. Sachez que vous apprendrez ces règles en programmant.


Les optimisations

Les optimisations des compilateurs

Table des matières

  • I. Introduction
  • II. Optimisations de la vitesse d'exécution
    • II-A. Instructions
      • II-A-1. Ordonnancement des instructions
      • II-A-2. Choix des instructions
      • II-A-3. Vectorisation
    • II-B. Calculs
      • II-B-1. Simplifications algébriques
      • II-B-2. Élimination des calculs constants
      • II-B-3. Propagation des constantes
      • II-B-4. Suppression des calculs redondants
      • II-B-5. Réduction en force
      • II-B-6. Invariants de boucles
      • II-B-7. Suppression des calculs inutiles
    • II-C. Optimisations liées aux branchements
      • II-C-1. Inlining
      • II-C-2. Bound checking
      • II-C-3. Branchements dont la condition est constante
      • II-C-4. Fusion de branchements
      • II-C-5. Déroulage de boucles
      • II-C-6. Fusion de boucles
      • II-C-7. Loop unswitching
      • II-C-8. Inversion de boucle
    • II-D. Optimisations liées à la hiérarchie mémoire
      • II-D-1. Rematérialisation
      • II-D-2. Loop spliting
      • II-D-3. Échange de boucles
  • III. Optimisations de la taille du code
    • III-A. Choix des instructions
    • III-B. Jonction de branchements
    • III-C. Abstraction de procédure
    • III-D. Suppression de code mort
    • III-E. Autres optimisations
  • IV. Conclusion

I. Introduction

Comme vous le savez sûrement, les compilateurs effectuent des optimisations sur le code des programmes compilés. Ces optimisations peuvent impliquer le code source lui-même, ou l'assembleur généré par le compilateur. Beaucoup pensent que les compilateurs sont de vrais magiciens, et qu'il est inutile d'optimiser soi-même un code source. Mais on peut quand même se demander ce que peut faire le compilateur sur notre code source. Et c'est le but de cet article que de vous l'expliquer.

Une bonne connaissance de l'architecture des ordinateurs est requise pour la compréhension de ce cours. Pour comprendre l'essentiel du cours, une connaissance des concepts de base suffit : savoir ce qu'est une instruction, avoir quelques rudiments en assembleur ou sur le langage machine, connaître la hiérarchie mémoire, etc. Quelques remarques isolées demandent des connaissances sur la microarchitecture des processeurs modernes : pipeline, exécution dans le désordre. De plus, les exemples de code seront en C/C++.

II. Optimisations de la vitesse d'exécution▲

Un compilateur peut optimiser plusieurs choses :

  • la taille du programme ;
  • la vitesse d'exécution ;
  • la consommation énergétique du programme.

Si les compilateurs sont très bons pour optimiser la vitesse d'exécution, il n'en est pas vraiment de même pour la taille du code ou la consommation énergétique. Quoi qu'il en soit, il existe plusieurs façons pour optimiser la vitesse d'exécution :

  • utiliser au mieux les instructions du processeur ;
  • diminuer le temps processeur passé à faire des calculs ;
  • virer des branchements et fonctions pour obtenir un code le plus linéaire possible ;
  • utiliser au mieux la hiérarchie mémoire, que ce soit au niveau des registres, du cache ou de la DRAM.

II-A. Instructions▲

Les premières méthodes tentent d'utiliser au mieux les instructions fournies par le processeur.

II-A-1. Ordonnancement des instructions▲

Tout d'abord, le compilateur est capable de changer l'ordre des opérations pour profiter au mieux du fonctionnement en pipeline(1) des processeurs modernes. Et à ce petit jeu, aucun programmeur ne peut rivaliser.

II-A-2. Choix des instructions▲

La seconde méthode consiste à utiliser les instructions machine les plus adaptées, histoire d'obtenir quelques simplifications. Par exemple, il est possible de remplacer certaines instructions par d'autres, plus rapides. L'exemple le plus connu est celui des multiplications/divisions par une puissance de deux constante qui se transforme facilement en décalage.

Une autre méthode consiste à fusionner des suites d'instructions élémentaires en une seule instruction. Par exemple, prenons le cas d'un processeur qui possède l'instruction MAD qui permet d'effectuer le calcul A + B * C en une seule instruction. Dans un code source écrit dans un langage de haut niveau, on ne peut pas utiliser celle-ci directement, et c'est au compilateur de fusionner une addition avec une multiplication en une seule instruction MAD.

Il est strictement inutile de faire ce genre de chose à la main, à une exception près : les processeurs de traitement de signal. Sur ces processeurs, coder en assembleur permet d'utiliser les modes d'adressage modulo et bit-reverse que le compilateur ne peut pas utiliser facilement.

II-A-3. Vectorisation▲

Le compilateur peut aussi tenter d'utiliser des instructions vectorielles quand c'est possible. Les techniques qui permettent au compilateur de découvrir des suites d'instructions qui peuvent profiter des instructions SIMD(2) des processeurs modernes sont ce qu'on appelle de la vectorisation.

Et à ce petit jeu, un bon programmeur donne un résultat nettement meilleur qu'un compilateur : ces derniers ont beaucoup de mal à optimiser les codes vectoriels. Bien sûr, de telles optimisations ne sont pas évidentes, mais elles peuvent permettre d'obtenir des codes de trois à quatre fois plus rapides !

II-B. Calculs▲

L'optimisation des calculs se base sur un principe simple : certains calculs peuvent être simplifiés, voire totalement éliminés.

II-B-1. Simplifications algébriques▲

Un compilateur peut, sous certaines conditions, effectuer des simplifications algébriques. De nombreuses optimisations de ce genre sont possibles pour les entiers : a * b + a * c qui se transforme en a * ( b + c ). Cependant, ces optimisations ne sont pas toujours possibles : par exemple, calculer -( a - b ) avec des entiers ne peut pas être simplifié en -a + b à cause d'une possibilité d'overflow assez bien cachée. Quelque chose de similaire a lieu pour les flottants : les opérations flottantes ne sont pas associatives ni distributives. Mais certains compilateurs ne se gênent pas pour faire les simplifications, à partir d'un certain niveau d'optimisation.

On trouve aussi la même chose pour les booléens, sous certaines conditions. Par exemple, l'expression if(!a && !b) peut être simplifiée en if (! (a || b) ). Mais ces optimisations ne sont pas toujours possibles, modifier l'ordre des comparaisons ou en supprimer peut ne pas donner le même résultat. Dans un tel cas, sachez que simplifier ces expressions à la main permet d'améliorer la lisibilité de votre programme (même si c'est souvent une amélioration marginale).

II-B-2. Élimination des calculs constants▲

Certains calculs dans un programme sont totalement constants. Par exemple, des calculs du style : int a = 2 * 3.1415926535 sont simplifiables. De même, un compilateur peut réduire de nombreux calculs du type a + 0, a * 0, a * 1, etc. Dès qu'une expression a tous ses opérandes constants, un compilateur peut parfois calculer le résultat lui-même et remplacer l'expression par son résultat : on parle de constant folding.

Mais attention, cela n'est valable que dans un cas bien précis : celui des expressions et fonctions qui donnent le même résultat pour des opérandes identiques. Il existe des calculs qui ne respectent pas cette règle, notamment certaines fonctions : ce sont des expressions ou fonctions dites impures. Et un compilateur n'a pas vraiment de moyens pour savoir si une fonction est pure ou impure : il devrait regarder et analyser son code, ce qui demanderait trop de ressources pour un résultat qui n'en vaut pas le coup. Dans ces conditions, le compilateur considère toutes les fonctions comme étant impures. La conséquence, c'est que de très nombreuses optimisations seront rendues impossibles si jamais on trouve une fonction au mauvais endroit dans le code source.

II-B-3. Propagation des constantes▲

Les compilateurs sont aussi capables de propager ces constantes. Cela veut dire remplacer les noms de variables dont la valeur est constante par la constante elle-même.

Par exemple, le code suivant :

Sélectionnez
const int a = 2;
const int b = 6;
const int c = 8;

int delta = b*b - 4*a*c;

peut être remplacé par :

Sélectionnez
const int a = 2;
const int b = 6;
const int c = 8;

int delta = 6 * 6 - 4 * 2 * 8;

On peut remarquer que cette optimisation se marie particulièrement bien avec le constant folding. Dans l'exemple du dessus, combiner les deux optimisations permet de calculer le résultat directement :

Sélectionnez
const int a = 2;
const int b = 6;
const int c = 8;

int delta = - 28;

II-B-4. Suppression des calculs redondants▲

Certains calculs sont inutiles ou redondants : il arrive souvent que des expressions calculent la même valeur que ce soit dans le code source ou dans la représentation intermédiaire d'un programme. Dans ce cas, une valeur est calculée plusieurs fois à des endroits relativement proches, par des suites d'instructions séparées.

Par exemple, prenons ce cas de figure :

Sélectionnez
#include <limits>

float solve_secondDegre_equation(float a, float b, float c)
{
    if (b*b - 4*a*c > 0)
    {
        return ( -b + sqrt (b*b - 4*a*c) ) / (2 * a) ;
    }
    else if (b*b - 4*a*c == 0)
    {
        return -b / (2 * a) ;
    }
    else
    {
        return std::quiet_NaN();
    }
}

Comme vous le voyez, le delta (b*b - 4*a*c) est recalculé plusieurs fois. Un compilateur pourrait remplacer automatiquement ce code redondant par ceci :

Sélectionnez
#include <limits>

float solve_second_Degre_equation(float a, float b, float c)
{
    float temp = 2 * a ;
    float temp2 = b*b - 4*a*c ;

    if (temp2 > 0)
    {
        return ( -b + sqrt (temp2) ) / temp ;
    }
    else if (temp2 == 0)
    {
        return -b / temp ;
    }
    else
    {
        return std::quiet_NaN();
    }
}

Le compilateur peut stocker cette valeur dans une variable, variable qui est alors réutilisée en lieu et place du résultat des expressions redondantes. Expressions redondantes qui sont alors supprimées.

Cependant, le compilateur ne peut rien faire si la valeur est calculée par une fonction impure. Quand le compilateur voit que deux fonctions impures sont appelées avec des arguments identiques, il ne peut pas savoir si le résultat renvoyé sera le même et ne peut pas remplacer la seconde exécution de la fonction par une simple lecture de variable.

Il faut toutefois signaler qu'il y a des exceptions à cette exception. Par exemple, prenez le code suivant :

Sélectionnez
for (unsigned i = 0; i < strlen(string); ++i)
{
   ...
}

Un bon compilateur C peut parfaitement optimiser de lui-même l'usage de la fonction strlen, et donner un code assembleur équivalent au code suivant :

Sélectionnez
unsigned length = strlen(string);

for (unsigned i = 0; i < length; ++i)
{
   ...
}

Quoi qu'il en soit, éviter des expressions longues comme le bras est un plus clairement appréciable en termes de lisibilité, et factoriser des expressions dans des variables correctement nommées est alors d'une grande aide.

II-B-5. Réduction en force▲

Les boucles, et notamment celles qui parcourent un tableau sont aussi une source d'optimisations.

Prenez ce code source :

Sélectionnez
for (int i = 0; i < ArraySize; ++i)
{
    Array[i] = i;
}

Cela ne se voit pas au premier abord, mais il y a une multiplication cachée dans cette boucle. Pour cela, il faut savoir que l'adresse de l'élément d'indice i est calculée en effectuant le calcul suivant : Adresse de base du tableau + (indice * taille d'un élément). Cette adresse se calcule donc à partir de l'indice de boucle avec une équation de la forme : Constante A + indice * Constante B. Dans une boucle, toute variable dont la valeur suit une équation du type A + (indice * B) s'appelle une variable d'induction.

Et bien ce genre de calcul peut se simplifier : il suffit de remarquer qu'à chaque tour de boucle, on ne fait qu'augmenter l'adresse de la quantité B. Le code se simplifie alors en :

Sélectionnez
char* base = A;

for (int i = 0; i < ArraySize; ++i)
{
    base += B;
    *pointer = i;
}

Autrefois, cette optimisation était effectuée à la main. D'ailleurs, nombreux sont les programmeurs C qui pensent qu'effectuer cette optimisation à la main est encore utile de nos jours. Toutefois, aujourd'hui, le compilateur sait automatiquement reconnaître les variables d'induction. Dans ce cas, il remplace cette équation par une addition, comme vu au-dessus, ce qui permet de supprimer une multiplication. Cette optimisation s'appelle la réduction en force.

II-B-6. Invariants de boucles▲

Les boucles sont définitivement une source d'optimisation assez importante. Il faut dire qu'un calcul dans une boucle sera exécuté de nombreuses fois (une fois par itération), ce qui fait que supprimer un seul calcul peut faire gagner quelques milliers de cycles d'horloge. Dans ces conditions, il faut sortir un maximum de choses des boucles.

Or, il arrive que certains calculs fournissent le même résultat à chaque tour de boucle : on parle d'invariant de boucles. Dans ces conditions, il vaut mieux sortir ces invariants de la boucle, histoire de ne les exécuter qu'une seule fois. Le compilateur sait détecter ces invariants : ils ne dépendent pas de l'indice de la boucle, ni de variables qui en dépendent indirectement. Inutile de faire cela à la main, donc. Sauf dans le cas des fonctions dont le compilateur ne peut dire si elles sont pures ou impures, encore une fois.

II-B-7. Suppression des calculs inutiles▲

Il arrive aussi que certains calculs soient inutiles. Par inutiles, on veut dire que leur résultat n'est pas utilisé dans le programme. Plus précisément, ces résultats sont écrits dans une variable qui n'est jamais lue. Il suffit que la variable soit dans une fonction pour que le compilateur puisse détecter qu'une variable ne sera jamais utilisée. Le compilateur peut alors supprimer les instructions qui ne servent qu'à calculer ce résultat, et ne servent à aucun autre calcul. Ces calculs inutiles apparaissent souvent après que la propagation de constantes a fait son travail.

II-C. Optimisations liées aux branchements▲

Les branchements sont une véritable plaie pour les processeurs modernes. Ils pourrissent le pipeline, sont parfois difficiles à prédire par l'unité de prédiction de branchement, etc. Mais ils sont aussi une véritable plaie pour le compilateur : les branchements empêchent certains déplacements de calculs : il est impossible de déplacer un calcul avant un branchement, de le faire sortir de la fonction qui le contient, etc. En conséquence, le compilateur cherche toujours à éliminer les branchements.

II-C-1. Inlining▲

Une optimisation bien connue est l'inlining. Cette technique consiste à éliminer les appels de fonction d'un programme : le corps de la fonction est intégralement recopié à chaque endroit où celle-ci est appelée. Il faut remarquer que cette technique a tendance à fortement augmenter la taille du code : au lieu d'un seul exemplaire de la fonction, celle-ci est recopiée en autant d'exemplaires qu'il y a de sites d'appel. Aussi, elle n'est effectuée que pour des fonctions relativement petites.

Cela permet d'éliminer l'appel de la fonction, ainsi que tout le code qui prépare l'appel. Dans certains cas, cela permet aussi d'améliorer l'usage de la mémoire cache (pas de saut dans la mémoire). L'inlining permet aussi à d'autres optimisations de fonctionner plus efficacement, comme la propagation de constantes. Il permet aussi d'obtenir des blocs de code linéaires de « grande taille », permettant au réordonnancement d'instructions de fonctionner au mieux.

II-C-2. Bound checking▲

Dans certains langages de programmation, les accès à un tableau avec un indice invalide vont lever une exception ou faire planter le programme. Pour cela, le compilateur insère des comparaisons qui vérifient la validité de l'indice. Ces comparaisons prennent évidemment du temps de calcul, et le compilateur dispose d'optimisations pour en éviter qui seraient inutiles. Il peut notamment prouver que certains morceaux de code ne peuvent pas donner lieu à des accès en dehors du tableau, pour éviter d'ajouter les tests de validité de l'indice. C'est notamment le cas avec ce code :

Sélectionnez
int sum = 0;

for (int i = 0; i < Array.size(); ++i)
{
    sum += Array[i];
}

II-C-3. Branchements dont la condition est constante▲

Une autre source d'optimisations provient des branchements de type if..else dont la condition est constante. Les conditions toujours vraies ou fausses apparaissent souvent suite à la propagation de constantes. Si jamais une condition se retrouve être toujours vraie ou fausse, alors le bloc if..else qui correspond est supprimé et seule la portion qui sera toujours exécutée sera alors intégrée dans le programme.

Par exemple, le code suivant :

Sélectionnez
int a;

if (true)
{
    a += 6;
}
else
{
    a += 9;
}

sera transformé en :

Sélectionnez
int a;

a += 6;

II-C-4. Fusion de branchements▲

Maintenant, prenons le cas d'un branchement A, dont l'instruction/adresse de destination est un autre branchement B, qui lui-même pointe sur une instruction C. Au cours de l'exécution, le branchement va envoyer le processeur sur B, qui va aussitôt brancher vers C : autant brancher directement vers C. Dans ce cas, le compilateur va modifier l'adresse de destination du branchement A pour qu'il pointe directement sur l'instruction C.

II-C-5. Déroulage de boucles▲

Le compilateur peut aussi effectuer des transformations sur les boucles. La transformation la plus basique est ce qu'on appelle le déroulage de boucles. Dérouler des boucles consiste à effectuer plusieurs tours de boucle d'un seul coup, en recopiant le corps de la boucle en plusieurs exemplaires. Cette technique est clairement utile pour diminuer le nombre de branchements exécutés : vu qu'on exécute les branchements à chaque itération, diminuer le nombre d'itérations permet d'en diminuer le nombre également. Évidemment, le nombre d'itérations est modifié de manière à obtenir un résultat correct, afin de ne pas faire des itérations en trop.

Exemple, prenons cette boucle :

Sélectionnez
int indice;

for (indice = 0; indice < taille_tableau; indice = indice + 1)
{
    a[indice] = b[indice] * 7;
}

Celle-ci peut être déroulée comme suit :

Sélectionnez
int indice;

for (indice = 0; indice < taille_tableau ; indice = indice + 4)
{
     a[indice] = b[indice] * 7;
     a[indice + 1] = b[indice + 1] * 7;
     a[indice + 2] = b[indice + 2] * 7;
     a[indice + 3] = b[indice + 3] * 7;
}

Pour obtenir un nombre d'itérations correct, les compilateurs utilisent généralement deux boucles : une qui est déroulée, et une autre qui traite les éléments restants. Par exemple, si je veux parcourir un tableau de taille fixe contenant 102 éléments, je devrais avoir une boucle comme celle-ci :

Sélectionnez
int indice;

for (indice = 0; indice < 100; indice = indice + 4)
{
     a[indice] = b[indice] * 7;
     a[indice + 1] = b[indice + 1] * 7;
     a[indice + 2] = b[indice + 2] * 7;
     a[indice + 3] = b[indice + 3] * 7;
}
for (indice = 100; indice < 102; ++i)
{
     a[indice] = b[indice] * 7;
}

Mais certaines boucles gagnent à être écrites de cette manière à la main, avec quelques optimisations. En effet, si je prends cette boucle :

Sélectionnez
unsigned somme = 0;

for (int i= 0; i < 10000; ++i)
{
     somme += tab[i];
}

le compilateur me donnera ceci :

Sélectionnez
unsigned somme = 0;

for (int i=0; i < 10000; i =  i + 4)
{
     somme += tab[i];
     somme += tab[i+1];
     somme += tab[i+2];
     somme += tab[i+3];
}

Or, le code le plus optimisé est celui-ci :

Sélectionnez
unsigned somme1 = 0;
unsigned somme2 = 0;
unsigned somme3 = 0;
unsigned somme4 = 0;

for (int i=0; i < 10000; i =  i + 4)
{
     somme1 += tab [i];
     somme2 += tab [i+1];
     somme3 += tab [i+2];
     somme4 += tab [i+3];
}

int somme = somme1 + somme2 + somme3 + somme4;

La raison est simple : les additions dans le corps de la boucle sont indépendantes les unes des autres, ce qui permet à un processeur superscalaire(3) ou à exécution dans le désordre d'effectuer chaque addition en parallèle. Et le même principe peut s'appliquer pour toute opération qui utilise une variable accumulateur dans une boucle, sous réserve que le calcul le permette (cela demande un calcul associatif).

II-C-6. Fusion de boucles▲

Cette technique consiste à fusionner deux boucles qui itèrent avec les mêmes indices en une seule.

Par exemple, le code suivant :

Sélectionnez
for (int j = 0; j < 100; ++j)
{
    array1[j] = 25;
}
for (int i = 0; i < 100; ++i)
{
    array2[i] = i;
}

donnera ceci :

Sélectionnez
for (int i = 0; i < 100; ++i)
{
    array1[i] = 25;
    array2[i] = i;
}

II-C-7. Loop unswitching▲

On a vu plus haut que les compilateurs sont capables de sortir certains calculs des boucles. Les calculs en question sont des calculs qui ne dépendent pas de l'indice de la boucle, que ce soit directement ou indirectement : on parle d'invariants de boucle. Il est possible d'utiliser l'extraction des invariants de boucle pour des conditions.

Par exemple, le code suivant :

Sélectionnez
for (i = 0; i < 1000; i++)
{
    if (w)
    {
        y[i] = 0;
    }
    else
    {
        x[i] += y[i];
    }
}

Deviendra :

Sélectionnez
if (w)
{
    for (i = 0; i < 1000; i++)
    {
        y[i] = 0;
    }
}
else
{
    for (i = 0; i < 1000; i++)
    {
        x[i] += y[i];
    }
}

II-C-8. Inversion de boucle▲

Enfin, dernière optimisation : transformer les boucles while (ou for, ne soyons pas raciste), en boucle do..while entourées par un if. L'astuce consiste à vérifier avec le if si la boucle doit être exécutée au moins une fois, et à exécuter la boucle do..while si c'est le cas. Si jamais le compilateur peut prouver que la boucle est exécutée au moins une fois, alors le if devient inutile. Cette optimisation permet de supprimer une exécution d'un branchement, sans compter qu'elle permet aussi à certaines optimisations de fonctionner au mieux, notamment la suppression des variables d'induction.

II-D. Optimisations liées à la hiérarchie mémoire▲

Si les branchements et les calculs sont une source d'optimisations, il ne faut pas oublier que la majorité des instructions d'un programme consistent en des accès mémoire. De nos jours, un bon usage de la hiérarchie mémoire est une condition sine qua non pour obtenir de bonnes performances, que ce soit pour un compilateur ou un programmeur. Autant dire que les compilateurs disposent de quelques optimisations sur le sujet. Mais pour autant, les compilateurs ne peuvent pas utiliser d'algorithmes cache oblivious à votre place, et encore moins changer des tableaux de structures en structures de tableaux. Soyez vigilant et optimisez à la main !

II-D-1. Rematérialisation▲

Il arrive souvent que l'on n'ait pas assez de registres pour stocker des variables temporaires. Dans ce cas, le contenu de certains registres est alors sauvegardé sur la pile pour une utilisation ultérieure : on parle de spilling. À ce petit jeu, les programmeurs assembleurs disposent d'une optimisation dont aucun compilateur actuel n'est capable : ils peuvent sauvegarder les registres, non pas sur la pile, mais dans les registres MMXMultiMedia eXtensions ou SSEStreaming SIMD Extensions. Mais si cette optimisation n'est pas possible automatiquement, il vaut mieux recalculer certaines variables temporaires que de les mémoriser temporairement dans un registre : on parle de rematérialisation. Cette optimisation est en quelque sorte l'inverse de celle qui supprime les expressions redondantes.

II-D-2. Loop spliting▲

Cette technique consiste à faire l'inverse de la fusion de boucle : des boucles qui itèrent sur des tableaux différents sont scindées en deux. Ainsi, chaque boucle travaillera indépendamment sur chaque tableau d'un bloc (on parcourt un tableau puis l'autre). Cette technique améliore la localité et la gestion du cache comparé à une seule boucle qui piocherait les données dans un tableau puis dans l'autre. Suivant la situation, cette optimisation sera plus ou moins efficace par rapport à la fusion de boucle : le compilateur dispose d'heuristiques pour déterminer quand utiliser telle ou telle optimisation suivant la situation.

II-D-3. Échange de boucles▲

Certains langages stockent les tableaux de tableaux ligne par ligne : des données d'une ligne sont consécutives en mémoire. Dans ces conditions, il vaut mieux parcourir les tableaux ligne par ligne, histoire de profiter de la mémoire cache du processeur et de diverses techniques de prefetching. D'autres langages fonctionnent colonne par colonne.

Dans ce cas, le compilateur peut parfois échanger l'ordre des boucles pour parcourir les tableaux ligne par ligne, ou colonne par colonne (selon le langage). Mais cela ne fonctionne que quand le compilateur peut prouver que la modification n'a pas d'effet de bord, ce qui n'est pas toujours possible. Par exemple, le compilateur n'a pas le droit de le faire dans un cas pareil :

Sélectionnez
for (int i = 0; i < nbLignes; ++i)
{
    for (int j = 0; j < nbColonnes; ++j)
    {
         tab[i][j] = fonction(i,j);
    }
}

Quand on sait que cette optimisation permet de gagner assez facilement en performances, parfois d'un facteur 10, mieux vaut être prudent et la faire à la main.

III. Optimisations de la taille du code▲

Si la vitesse des programmes peut être optimisée, les compilateurs peuvent aussi jouer sur la taille du programme. La taille d'un programme en elle-même n'est pas importante pour des programmes PC puisque la majorité d'entre eux possèdent plusieurs gibioctets(4) de RAM, ce qui laisse une grande marge. Par contre, diminuer la taille du code a des effets indirects sur les performances en économisant de la mémoire cache. Mais dans la réalité, les optimisations de la taille du code entrent souvent en conflit avec les optimisations de vitesse d'exécution.

III-A. Choix des instructions▲

La première optimisation consiste à choisir les instructions les plus courtes quand on peut utiliser plusieurs instructions pour une même opération. L'exemple classique sur les processeurs x86 est la mise à zéro d'un registre : il vaut mieux effectuer un XOR qu'un MOV. Par exemple, XOR EAX EAX est plus court que MOV EAX 0. Le seul problème, c'est que les instructions courtes ne sont pas forcément les plus rapides, or le compilateur a tendance à choisir des instructions plus longues, mais plus rapides.

III-B. Jonction de branchements▲

Il est possible que dans un if…else, certaines lignes de code soient recopiées à la fois dans le if et dans le else. Il arrive de plus que le résultat de ces lignes de code soit totalement indépendant du contenu du else ou du if.

Prenez cet exemple :

Sélectionnez
if (a > 0)
{
    --a;
    ++b;
}
else
{
    ++a;
    ++b;
}

On voit bien que la ligne ++b peut être factorisée vu qu'elle est totalement indépendante du contenu du if et du else.

Un compilateur peut effectuer automatiquement cette transformation, ce qui donne :

Sélectionnez
++b;

if (a > 0)
{
    --a;
}
else
{
    ++a;
}

III-C. Abstraction de procédure▲

Une autre technique très utile est l'abstraction de procédure. Cette méthode consiste à détecter des morceaux de code présents en plusieurs exemplaires dans un code source et les transformer en fonction : les exemplaires sont alors retirés du programme, et sont remplacés par un appel à la fonction créée. On peut voir cette technique comme l'exact inverse de l'inlining.

Autant vous dire que décider quand inliner ou utiliser l'abstraction de procédure est généralement un véritable casse-tête. Généralement, les compilateurs disposent de paramètres de compilation pour spécifier s'il faut optimiser pour la taille du code ou pour la vitesse : l'abstraction de procédure est utilisée quand on choisit l'option taille du code, alors que l'inlining est utilisé quand on choisit l'option vitesse.

III-D. Suppression de code mort▲

Un compilateur peut aussi détecter les morceaux de code qui ne seront jamais exécutés. Par exemple, il est capable de remarquer que le code d'un else ou d'un if dont la condition est constante ne sera jamais exécuté et peut alors supprimer ce code. Cela permet de gagner de la place assez facilement, surtout quand la propagation de constante fait son petit effet.

III-E. Autres optimisations▲

Il faut aussi signaler que presque toutes les optimisations qui permettent de simplifier les calculs ou de virer les calculs inutiles permettent de diminuer la taille du code. Enfin, il faut préciser que la fusion de boucles permet de diminuer la taille du code, vu que des branchements sont supprimés.

IV. Conclusion▲

Et voilà, nous avons vu une partie des optimisations que tout compilateur digne de ce nom effectue automatiquement sur votre code source. Bien sûr, un compilateur peut effectuer d'autres optimisations un peu plus complexes sur votre code source, mais les optimisations vues au-dessus sont un aperçu assez intéressant, suffisant pour avoir une bonne idée de ce qui se passe quand vous compilez un programme.

Le contenu de cet article est rédigé par Guy Grave et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d’Utilisation Commerciale - Pas de Modification 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2013 Developpez.com.
Contacter le responsable de la rubrique Hardware
  • Nous contacter
  • Participez
  • Hébergement
  • Informations légales

Copyright © 2000-2016 - www.developpez.com


Sommaire

Le langage C
Vu dans .... http://progdupeu.pl/tutoriels/15/le-langage-c/introduction/

Vous souhaitez apprendre à programmer, mais vous ne savez pas comment vous y prendre ? Ou bien vous voulez réviser ? Alors, permettez-nous de vous souhaiter la bienvenue dans ce cours de programmation en C pour débutants. La programmation est devenue aujourd'hui incontournable, si bien qu’elle est utilisée partout. Tous les logiciels de votre ordinateur ont été programmés. Et ce cours va vous apprendre les bases de la programmation en C pour vous permettre de créer des programmes à votre tour.

Pour pouvoir suivre ce tutoriel, vous n’avez aucun pré-requis ; tout sera détaillé de la manière la plus complète possible. Nous commencerons par une introduction à la programmation et au C, puis nous avancerons peu à peu dans l'univers de la programmation, tout cela grâce à des cours, des exemples, des exercices d’applications et des travaux pratiques.

Partie 1 : Introduction

Cette première partie sera l'occasion de se familiariser avec les concepts de base de la programmation et du C et de pouvoir commencer à pratiquer en écrivant quelques programmes. Lisez-la bien attentivement, même si vos avez déjà une expérience en programmation, car il est impossible de bien programmer en C si les bases ne sont pas solides.

  • Chapitre 1.1 | Introduction à la programmation
    • Avant-propos
    • La programmation, qu’est-ce que c’est ?
    • Le langage C
    • L’algorithmique
  • Chapitre 1.2 | Outils
    • Windows
    • GNU/Linux - UNIX
    • Mac OS
  • Chapitre 1.3 | Rencontre avec le C
    • Notre cible
    • Analyse du code source
    • Notions de base

Chapitre 1.4 | Les variables

    • Qu’est-ce qu’une variable ?
    • Déclarer une variable
    • Utiliser des variables
  • Chapitre 1.5 | Manipulations basiques des entrées/sorties
    • Les sorties
    • Interagir avec l'utilisateur
    • Exercice
  • Chapitre 1.6 | Les conditions
    • Conditions et booléens
    • Les opérateurs logiques
    • La structure if
    • L'instruction switch
    • L'opérateur ternaire
  • Chapitre 1.7 | Les boucles
    • La boucle while
    • La boucle do-while
    • La boucle for
    • Branchements inconditionnels
    • Exercices
  • Chapitre 1.8 | Les fonctions
    • A quoi ca sert ?
    • Déclarer une fonction
    • Utiliser une fonction
    • Les prototypes
    • Exercices
  • Chapitre 1.9 | Découper son projet
    • Portée et masquage
    • Diviser pour mieux régner
    • Les fichiers d'en-têtes
  • Chapitre 1.10 | Le préprocesseur
    • Le fonctionnement du préprocesseur
    • Une directive : #define
    • Des directives de condition
    • Constantes pré-définies
  • Chapitre 1.11 | L'en-tête <math.h>
    • Les bibliothèques
    • Découvrir <math.h>
    • TP : Recodons des fonctions mathématiques

Cette première partie nous a présenté de nombreuses notions, n'hésitez pas à la relire si vous en ressentez le besoin. Cependant, le C ne se limite pas qu'à ces notions de bases. En effet, il existe des concepts plus poussés, plus complexes mais plus puissants que nous allons découvrir dans la partie 2.

Partie 2 : Les agrégats

Dans cette seconde partie, nous allons aborder des aspects du C certes plus complexes mais aussi beaucoup plus puissants que ceux que nous avons vu : les agrégats, c'est à dire un regroupement de plusieurs notions différentes. Ne soyez pas rebutés s'il y a des choses que vous ne comprenez pas de prime abord : c'est normal, tout le monde est passé par là. Relisez à tête reposée, testez, lisez sur Internet, bref, ne restez pas bloqués. Vous verrez que le jeu en vaut la chandelle puisque nous pourrons faire beaucoup plus de choses dans nos programmes avec ces nouveaux concept !

  • Chapitre 2.1 | Les pointeurs
    • C'est quoi ?
    • Utilisation
    • Exercice
  • Chapitre 2.2 | Structures
    • Déclaration, définition et initialisation
    • Utilisation et pointeurs
    • Un peu de mémoire
    • Compléments
  • Chapitre 2.3 | Les tableaux
    • C'est quoi un tableau ?
    • Déclaration et initialisation
    • Accès aux éléments
    • Tableaux et fonctions
    • Exercices
  • Chapitre 2.4 | Les tableaux multidimensionnels
    • Déclaration et initialisation
    • Accès à un élèment
    • Envoi à une fonction
    • Exercices
  • Chapitre 2.5 | Les chaînes de caractères
    • Qu'est ce qu'une chaîne de caractères ?
    • Déclaration et initilisation
    • Utilisation d'une chaine
    • Conversion de chaînes de caractères en nombres
    • Lire et écrire dans une chaîne
  • Chapitre 2.6 | L'en-tête <string.h>
    • Longueur
    • Copie
    • Comparaisons
    • Recherche
    • Autres
    • Exercices
  • Chapitre 2.7 | L'allocation dynamique
    • Durée de vie
    • Malloc et consoeurs

Voilà, c'est la fin de la partie 2, qui a été bien dense et pleine de nouveaux concepts bien puissants. Si jamais certaines parties vous posent des soucis, relisez-les autant qu'il faut, puisque les informations contenues dans cette partie sont vitales pour bien programmer en C et comprendre la suite de ce tutoriel. D'ailleurs, nous allons voir dans les parties suivantes que ces concepts combinés ensembles peuvent être très pratiques.

Partie 3 : Les entrées-sorties

Pour l'instant, nous ne connaissons rien aux entrées-sorties hormis les quelques fonctions vues dans les parties précédentes. Et pourtant la bibliothèque standard contient foule de fonctions capables de pleins de choses, dont ouvrir des fichiers, écrire dedans, signaler des erreurs, etc. Cette partie sera donc l'occasion non seulement des les découvrir, mais également d'apprendre à sécuriser les flux d'entrées et de sorties pour éviter les bêtises. L'un des principes du C est en effet de ne jamais croire l'utilisateur, celui-ci pouvant potentiellement faire n'importe quoi.

  • Chapitre 3.1 | Les flux
    • Un peu de théorie
    • Ouverture et fermeture de flux
    • Lecture d'un flux
    • Écriture dans un flux
    • D'autres fonctions
  • Chapitre 3.2 | Entrées sécurisées
    • Les dangers de scanf
    • Des alternatives
    • Comment bien utiliser scanf ?
  • Chapitre 3.3 | T.P - zAnalyse
    • Énoncé

Finalement, les flux ce n'est pas toujours une partie de plaisir, surtout en C. Nous avons vu par exemple comment sécuriser les entrées, mais ce n'est pas parfait. Ce que vous devez retenir de cette partie, c'est qu'il faut savoir garder un équilibre : certes il faut sécuriser ses entrées, mais ne passez pas votre temps à ça.

Partie 4 : Pour aller plus loin

Comme vous devez vous en douter, nous ne pouvons tout vous montrer sur le C ni prétendre être exhaustif. En effet, un langage de programmation, c'est comme un langue, on en apprend toujours un peu plus au fur et à mesure, mais on ne peut jamais vraiment le maitriser. Nous avons donc décidé dans cette partie finale de vous parler de quelques points du C que nous n'avions pas abordés jusqu'ici, utilisés un peu plus rarement que tout ce que nous avons vu jusqu'à maintenant, mais qu'il est toujours bien et utile d'apprendre. Nous ferons également une présentation des normes C99 et C11 ainsi qu'un bref aperçu de quelques bibliothèques connues pour ceux qui veulent continuer à apprendre. C'est parti pour la dernière ligne droite !

  • Chapitre 4.1 | Opérations bit à bit et champs binaires
    • Les opérateurs bitwise
    • Le masquage et les champs de bits
    • Quelques astuces
  • Chapitre 4.2 | Enumérations et unions
    • Les énumérations
    • Les unions
    • Exemple pratique : une abstraction pour les chaînes de caractères
  • Chapitre 4.3 | Découvrir le C99 / C11
    • Avant de démarrer
    • Découvrir le C99
    • Découvrir le C11
  • Chapitre 4.4 | Des bibliothèques par milliers
    • Le reste de la bibliothèque standard
    • Les bibliothèques tierces

Sachez qu'il vous reste encore beaucoup à apprendre sur le C ; en fait, personne ne peut se vanter de maîtriser complètement le C. N'hésitez donc pas à poursuivre vos recherches, approfondir ce que nous avons vu, mais également découvrir d'autres surprises que le C vous cache encore !

Ainsi s'achève ce tutoriel, mais pas votre parcours dans le monde de la programmation. En effet, même si vous avez appris certaines choses, vous ne connaissez pas tout : le C est un langage fabuleux qui réserve bien des surprises. Pour continuer votre apprentissage, je vous donne quelques conseils ci-dessous.

  • Soyez curieux : fouillez sur Internet pour découvrir de nouvelles méthodes, approfondissez celles que vous connaissez, renseignez-vous, testez de nouveaux outils, etc.
  • Codez et lisez du code : entrainez-vous, c'est le seul moyen de progresser. Faites des projets qui vous tiennent à cœur, implémentez des algorithmes connus, faites des exercices, des défis et exercez-vous.
  • Pensez aussi à visiter les forums et à lire le code des autres, découvrez comment ils procèdent, apprenez d'eux de nouvelles techniques ou façon de faire, progressez en suivant leurs conseils.
  • Cherchez toujours à en savoir plus : la programmation est un monde merveilleux où l'on en apprend un peu plus chaque jour, sans jamais s'arrêter. Lisez des tutoriels pour découvrir de nouveaux points.
  • Lisez des codes pour découvrir de nouvelles façons de faire. Expérimentez.
  • Et surtout le plus important, amusez-vous !


Outils

Maintenant que les présentations sont faites, il est temps de découvrir les outils nécessaires pour programmer en C. Le strict minimum pour programmer se résume en trois points.

  • Un éditeur de texte : ce logiciel va servir à écrire le code source. En théorie, n’importe quel éditeur de texte suffit, mais le mieux est d’en avoir qui colore le code source, ce qui permet une relecture plus agréable.
  • Un compilateur : c’est le logiciel le plus important puisqu’il va nous permettre de transformer le code que l’on écrit en un fichier exécutable compréhensible par le processeur.
  • Un débugger / débogueur (prononcez « débegueur ») : fondamentalement, il n’est pas indispensable, mais ce logiciel est très utile pour chasser les bugs et vérifier le comportement de son programme.

À partir de là, il existe deux moyens de récupérer tous ces logiciels : soit on les prend séparément, et dans ce cas il faut compiler par soi-même, soit on utilise un logiciel qui réunit les trois : un IDE (EDI en français).

Face à la multitude de logiciels différents qui existent, ce chapitre a pour but de vous guider en vous montrant quelques logiciels, que ce soit pour compiler à la main ou avec un IDE.

  • Windows
  • GNU/Linux - UNIX
  • Mac OS

Windows

Bien que de nombreux IDE soient disponibles pour Windows, nous ne parlerons que de deux d’entre eux : Code::Blocks et Visual C++, sans oublier une partie consacrée à la compilation ''via'' l’invite de commande.

Avec un IDE

Code::Blocks

Code::Blocks est un IDE gratuit et libre (vous pouvez obtenir le code source du logiciel si vous le souhaitez), qui fonctionne avec plusieurs compilateurs différents et qui n’est pas très compliqué à prendre en main. Il n’est cependant disponible qu’en anglais (bien qu’il existe des traductions incomplètes en français) ; néanmoins, avec un dictionnaire et de l’intuition, vous vous en sortirez très bien.

Pour télécharger Code::Blocks, rendez-vous sur le site officiel, dans la section « Downloads », puis dans la sous-section « Download the binary release ». Cette section vous permettra de télécharger le logiciel ; contrairement à la section « Download the source code' » qui sert à télécharger le code source de Code::Blocks.

Il va falloir ensuite télécharger la version du logiciel adaptée à votre système d’exploitation.

  • Windows : choisissez « codeblocks-XX.XXmingw-setup.exe » pour télécharger la version de Code::Blocks pour Windows avec un compilateur intégré. Si vous choisissez la première, vous ne pourrez pas compiler vos programmes ! Je le répète donc encore une fois : choisissez la version avec mingw dans le nom. Pour information, MinGW est une adaptation pour Windows du compilateur GCC.
  • Linux : choisissez la version qui correspond à votre distribution. Attention à ne pas confondre les versions 32 bits et 64 bits.
  • Mac : téléchargez le fichier proposé.

Une image pour bien comprendre :

La bonne version à télécharger est entourée de rouge

Si cependant vous êtes expérimentés et que vous souhaitez installer votre propre compilateur, vous pouvez prendre la première version. Ensuite pour l’installation, laissez-vous guider, elle est très simple. Une fois l’installation terminée, en lançant Code::Blocks, vous devriez obtenir ceci :

Page principale au lancement de Code::Blocks

Cela vous parait compliqué ? Je vais tout vous expliquer dans quelques secondes. Avant, j’aimerais qu’on crée un projet, pour que je puisse vous illustrer tout ça. Pour ce faire, deux possibilités : ou vous cliquez sur « Create a new project » dans le menu de démarrage, ou bien vous cliquez sur « File -> New -> Project ». Dans tous les cas, vous tombez sur cette fenêtre :

Fenêtre de choix de création de projet

Choisissez l’icône « Console application », entourée en gras sur l’image. Puis double-cliquez dessus ou cliquez sur le bouton « Go » pour créer un projet de type console.

Le premier menu est juste un menu informatif, cliquez sur « Next ». La page suivante vous demande quel langage vous voulez utiliser. Sélectionnez « C » puis « Next ». Vous arrivez ensuite sur cette fenêtre :

Première fenêtre lors de la création d'un projet

Là, il y a plusieurs champs.

  • Project title : c’est le nom que vous souhaitez donner à votre projet. Un même nom ne peut pas être utilisé plusieurs fois, il faut un nom différent pour chaque projet.
  • Folder to create project in : c’est le répertoire dans lequel le projet sera créé.
  • Project filename et resulting filename : ces champs sont remplis automatiquement par Code::Blocks, on ne s'en préoccupe pas.

Ensuite, dernière fenêtre :

Deuxième fenêtre lors de la création d'un projet

  • Compiler : permet de choisir le compilateur que l’on veut utiliser. Ici, comme il n’y a que ce compilateur d’installé, on n’y touche pas.
  • Create "Debug" configuration : cochez cette case pour avoir un exécutable compilé en mode Debug, c’est-à-dire un programme non optimisé qui contiendra toutes les informations nécessaires pour déboguer. L’exécutable ne sera pas portable.
  • Create "Release configuration''' : le programme est optimisé, portable et allégé puisqu’il ne possède plus les informations de débogage.

Choisir entre les deux modes importe peu pour l’instant. Il faut simplement que l’un des deux au moins soit coché. Cliquez sur « Finish » pour terminer la création du projet. Maintenant, vous devez avoir une fenêtre comme celle-ci :

Page principale de Code::Blocks après la création d'un projet

Je pense que quelques explications ne seraient pas de refus.

  1. C’est la liste des menus. Certains seront très utilisés, tandis que d’autres presque pas. Retenez que le menu « File » est l’un des plus utilisés.
  2. Ce sont les icônes. Voici les quatre principales :
    • Icône Build -> c'est l'icône « Build », qui sert à compiler le fichier sans le lancer ; le raccourci clavier est Ctrl + F9.
    • Icône Run -> c’est l’icône « Run », qui lance le dernier exécutable compilé ; le raccourci clavier est Ctrl + F10.
    • Icône Build & Run -> c’est l’icône « Build & Run », la contraction des deux icônes précédentes : elle compile et exécute ; le raccourci clavier est F9.
    • Icône Rebuild -> c’est l’icône « Rebuild », qui sert à recompiler tous les fichiers ; par défaut, Code::Blocks ne les recompile pas tous (seuls ceux qui ont été modifiés sont recompilés) ; le raccourci clavier est Ctrl + F11.
  3. C’est la zone des projets. C’est ici que vous pouvez voir tous les fichiers qui composent votre projet. Vous pouvez même avoir plusieurs projets en même temps, mais vous ne pouvez en compiler qu’un à la fois.
  4. C’est la zone principale, car c’est ici que l’on écrit le code source.
  5. C’est la zone de notification où apparaissent les erreurs, les messages de compilation, les messages du débogueur, ainsi que les les avertissements.

Vous pouvez voir que Code::Blocks a généré un code par défaut. Nous allons le compiler. Utilisez les icônes ou les raccourcis clavier pour se faire. Il se peut que vous obteniez un message d'erreur comme celui-ci :

1
2

"My-program - Release ou Debug" uses an invalid compiler. Skipping...
Nothing to be done.

Si cela vous arrive, ne paniquez pas. Il y a deux causes possibles.

  • Vous utilisez Code::Blocks et vous avez téléchargé la version sans compilateur : dans ce cas, retournez sur le site officiel et prenez la version avec MinGW.
  • Vous avez la bonne version et dans ce cas c’est le chemin vers le compilateur MinGW qui est incorrect : rendez-vous dans « Settings -> Compiler&Debugger -> Toolchain executable », cliquez sur « … », et saisissez le répertoire « MinGW » dans votre installation (si vous avez installé Code::Blocks avec MinGW, celui-ci se trouve dans le répertoire de Code::Blocks), puis cliquez sur OK.

Une fois le problème réglé (si problème il y avait), le programme est compilé et un message apparait dans la console :

1
2
3
4

Hello world!

Process returned 0 (0x0)   execution time : x.xxx s
Press any key to continue.

La première ligne correspond à ce qu'affiche le programme. Les deux lignes suivantes sont elles spécifiques à Code::Blocks. La première indique à l'utilisateur si le programme s'est bien déroulé ou s'il y a eu erreur et le temps écoulé depuis le lancement. La seconde demande d'appuyer sur une touche pour continuer. En effet, sans cette dernière ligne, nous n'aurions pas pu voir le programme se lancer qu'il serait déjà terminé. Ce comportement est spécifique à Windows.

Visual C++

Visual C++ est un IDE édité par Microsoft et très efficace, car adapté pour Windows. Il possède aussi un débogueur puissant. Bien qu’il ne soit pas libre, Visual C++ est gratuit (dans sa version express) et disponible en de nombreuses langues, dont le français. Il suffit tout simplement d’enregistrer le logiciel pour l’utiliser sans limites de temps ; c’est gratuit et rapide, vous n’avez besoin que d’une adresse mail.

Pour télécharger Visual C++, rendez-vous sur le site de Microsoft. Cliquez ensuite sur l’onglet « Visual C++ 2010 Express ». Vous arriverez sur la page de téléchargement. Sélectionnez la langue que vous voulez puis cliquez sur « Télécharger ».

Le programme d’installation va se charger de tout télécharger et tout installer. À un moment, vous devrez redémarrer. Acceptez, et une fois le redémarrage terminé, l’installation finira tranquillement.

Voici à quoi ressemble Visual C++ 2010 Express :

Page principale après le lancement de Visual

Comme pour Code::Blocks, j’aimerais vous montrer la création d’un projet avant de vous expliquer l’image. Pour cela, deux possibilités : cliquez sur « Nouveau projet » au démarrage, ou bien « Fichier ->Nouveau -> Projet ». Vous devriez obtenir cette fenêtre :

Création de projet page 1

Pour créer un projet en console, sélectionnez « Application console Win32 », et donnez un nom à votre projet dans la case « Nom » en bas de la fenêtre. Une fois ceci fait, vous arrivez sur une fenêtre à propos des paramètres de votre projet. Cliquez sur « Suivant » en bas ou « Paramètres de l’application » dans la colonne à gauche. Vous devez tomber sur une fenêtre comme celle-ci :

Création projet page 2

Sélectionnez « Projet vide » pour commencer avec un projet vierge, sinon Visual va créé un projet avec des fichiers dont nous ne voulons pas.

Pour rajouter des fichiers, la manœuvre est très simple : faites un clic droit sur l’onglet « Fichiers sources » dans la colonne de gauche, puis allez dans « Ajouter -> Nouvel élément… ». Une petite image pour bien comprendre :

Ajouter un fichier

Une nouvelle fenêtre apparait alors pour vous demander quel type de fichier il faut ajouter au projet. Cliquez sur « Fichiers C++ (.cpp) » (même si ce type de fichier est normalement réservé au C++), et appelez votre fichier main.c. Il faut que le fichier se termine par .c, sinon Visual ajoutera automatiquement l’extension .cpp qui est celle des fichiers C++. Donc faites-y attention !

Et si nous examinions un peu les menus de Visual C++ ? Vous devriez normalement avoir une fenêtre comme celle-ci :

Page principale après la création d'un projet et l'ajout d'un fichier

Regardons plus attentivement ces quatre zones.

  1. La barre d’outils : elle contient tous les menus et les raccourcis (comme la compilation, la génération, etc), certains seront plus utilisés que d’autres.
  2. La zone principale : c’est ici que l’on écrira le code.
  3. L'explorateur de solutions : cette zone permet de gérer les fichiers qui composent notre projet. Visual y organise les fichiers en trois types : les fichiers sources, les fichiers ressources et les fichiers d’en-tête (nous verrons tout ça en temps voulu).
  4. La zone de notification : c’est dans cette zone qu’apparaissent les erreurs, les informations du débogueur, les avertissements et les messages de compilation.

Voici quelques raccourcis claviers pratiques que vous serez souvent amenés à utiliser :

  • F5 : lance l’exécutable en appelant le débogueur ;
  • Ctrl + F5 : lance l’exécutable sans appeler le débugger ;
  • F7 : génère une solution (compile) sans lancer le programme ;
  • Ctrl + Alt + F7 : régénère une solution.

Comme une liste de tous les raccourcis serait trop longue, voici la liste officielle (en anglais).

Essayons de mettre en pratiques quelques-uns de ces raccourcis en compilant un code minimal. Je vous fournis un code source que nous examinerons dans le chapitre suivant.

1
2
3
4
5
6
7

#include <stdio.h>

int main(void)
{
    printf("Hello world!\n");
    return 0;
}

Pour le compiler, on doit faire F7 puis Ctrl + F5. Cependant, pour allez plus vite, on peut faire directement Ctrl + F5. Si vous utilisez cette combinaison de touches, il se peut que vous tombiez sur une fenêtre semblable à celle-ci :

Fenêtre d'erreur de recompilation

Cela signifie qu'il y a eu des modifications dans le code et que la solution n'a pas été régénérée (on a pas recompilé). Dans ce cas, cliquez sur « Oui » pour régénérer la solution, ou cliquez sur « Non » pour lancer la dernière solution générée (le dernier exécutable compilé).

Avec l’invite de commande

Même si la programmation à l’aide d’un IDE peut être pratique, certains préfèrent néanmoins programmer à la main, c’est-à-dire s’occuper eux-mêmes de la compilation. Pour cela, ils utilisent l’invite de commande. Si jamais cette méthode vous tente et que vous avez les compétences nécessaires pour vous servir de l’invite, lisez cette partie.

Le compilateur

Le plus important dans tout ça est le compilateur. Je vous propose donc de télécharger MinGW, qui est une adaptation pour Windows du compilateur GCC, je vous le rappelle.

Rendez-vous sur le site de MinGW, puis dans le cadre de gauche dans la section « Download ». Pour se faciliter le travail, on va télécharger l’installateur. Pour cela, cliquez sur le lien en haut de la page « Looking for the latest version ? Download mingw-get-inst-xxxxxxxx.exe (xxx.x kB) ».

Exécutez le programme. Arrivés à la partie « Repository Catalogues », choisissez « Use pre-packaged repository catalogues » si vous voulez utiliser les outils fournis avec l’installateur, ou bien « Download latest repository catalogues » si vous voulez que l’installateur télécharge les tout derniers fichiers.

Ceci fait, acceptez la licence (lisez-la si vous en avez le courage), puis sélectionnez le dossier où vous souhaitez que MinGW soit installé. Ensuite, il faut choisir les composants que l’on veut installer. Normalement, seuls « MinGW Compiler Suite » et « C Compiler » sont cochés. Les autres cases ne nous intéressent pas puisque elles servent à installer des compilateurs pour d'autres langages. Laissez ensuite le programme finir son travail.

Maintenant il reste une dernière étape : configurer la variable d'environnement (PATH). Cette étape va permettre à l'Invite de Commande de comprendre les commandes de compilation de MinGW, sans quoi il serait impossible de compiler un programme.

  • Sous Windows XP et antérieur, il faut faire un clic-droit sur « Poste de travail » puis choisir « Propriétés ». Dans la fenêtre qui s'ouvre, cliquez sur « Avancés » puis sur « Variables d'environnement ».
  • Sous Windows Vista et Seven, il faut faire un clic-droit sur l'icône « Ordinateur » dans le menu Démarrer ou bien sur « Poste de travail ». Ensuite, cliquez sur « Paramètres systèmes avancés ». Dans la nouvelle fenêtre qui s'ouvre, allez dans « Paramètres systèmes avancés » et cliquez sur « Variable d'environnement ».

Dans la partie Utilisateur courant, créez une nouvelle variable et rentrez %PATH%;C:\MinGW\bin (le chemin après le point-virgule peut varier en fonction de où vous avez décidés d'installer MinGW, l'important est de bien avoir le répertoire bin à la fin).

L’éditeur de texte

L’éditeur de texte va nous permettre d’écrire notre code source et de l’enregistrer pour que le compilateur fasse son travail. L’idéal est d’avoir un éditeur de texte facile à utiliser et qui colore le code source, ce qui permet une meilleure relecture. Si jamais vous avez déjà un éditeur de texte et que vous l'appréciez, ne changez pas, il marchera très bien lui aussi.

Si cependant vous ne savez pas lequel prendre, je vais vous aider. Personnellement, j’utilise Notepad++, qui est simple, pratique et efficace. Pour le télécharger, rendez-vous sur la page de téléchargement, et sélectionnez « Notepad++ vX.X.X.X Installer » pour télécharger l’installateur. Pour l'installation je vous laisse faire, elle est facile.

Compiler à la main avec l’invite de commande

Testons tout ce que l’on vient d’installer en compilant un petit code simple que nous expliquerons dans le chapitre suivant.

1
2
3
4
5
6
7

#include <stdio.h>

int main(void)
{
    printf("Hello world!\n");
    return 0;
}

Copiez-collez ce code dans l’éditeur de texte, puis enregistrez le fichier sous le nom main.c. Ensuite, déplacez-vous dans les répertoires à l’aide de l’invite pour arriver dans le répertoire qui contient le fichier source.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

C:\Programmation>dir

Répertoire de C:\Programmation

07/12/2011  13:54    <REP>          .
07/12/2011  13:54    <REP>          ..
07/12/2011  13:54             130   main.c

1 fichier(s)              130 octets
2 Rép(s)  172 089 290 752 octets libres

Nous allons compiler ce fichier à l’aide d’une commande : gcc main.c. Cette commande va transformer le fichier spécifié en exécutable. Si vous regardez le répertoire de nouveau, vous remarquerez d’ailleurs qu'un fichier .exe est apparu. C’est le résultat de la compilation. Si vous le lancez, vous verrez le résultat à l'écran :

1
2
3
4

C:\Programmation>gcc main.c

C:\Programmation>main.exe
Hello world

Si vous obtenez une erreur du type « 'gcc' n'est pas reconnu en tant que commande interne ou externe, un programme exécutable ou un fichiers de commandes » c'est que vous vous êtes trompés quelque part.

Nous apprendrons dans le chapitre suivant pourquoi le programme affiche un message à l’écran.

Il existe de nombreuses options de compilation pour MinGW que tout un cours entier ne pourrait pas aborder. Si vous souhaitez découvrir ces options, vous pouvez jeter un œil à la documentation officielle. Même si cette page traite de GCC, la très grande majorité des options marchent pour MinGW.

GNU/Linux - UNIX

Le C étant très lié à UNIX, il existe de nombreux outils disponibles pour ces deux systèmes d’exploitation. Je vais vous en présenter quelques-uns. Afin d’éviter certains problèmes, je vous conseille fortement d’installer le paquet ''build-essential'' avant toute chose sous Debian et ses dérivés (Ubuntu, Kubuntu, etc. en font partis) : # aptitude install build-essential

Les IDE

Sous GNU/Linux et UNIX, il y a évidemment de nombreux IDE disponibles. Si vous souhaitez utiliser un IDE, je vous conseille Code::Blocks. Vérifiez dans vos dépôts s’il est disponible, et si jamais il ne l’est pas, rendez-vous sur la page de téléchargement du site officiel de Code::Blocks. Une fois que vous l’avez installé, regardez plus haut dans ce tutoriel pour vous familiariser avec lui.

Même si les IDE sont pratiques, beaucoup de programmeurs préfèrent compiler à la main sous ces plateformes. Je vous recommande donc de lire également la partie suivante, même si vous ne pensez pas compiler à la main.

La compilation en ligne de commande

La compilation à la main est prônée par de nombreux programmeurs experts. On dit souvent que ça présente de nombreux avantages. Cependant, pour le programmeur débutant, c’est légèrement différent. En effet, la compilation manuelle présente des avantages et des défauts :

  • rapide une fois prise en main et légère ;
  • permet d’apprendre plus de choses, voire même d’apprendre plus rapidement certains concepts ;
  • on doit cependant tout faire soi-même ;
  • parait compliqué et hostile ;
  • il faut savoir manipuler le terminal.

Le troisième argument est en orange puisque le fait de tout faire soi-même est très intéressant et est donc un avantage conséquent. Cependant, pour certains cette technique est assez difficile. Faire tout soi-même permet au programmeur d’avoir le contrôle absolu sur ce qu’il fait, contrairement à certains IDE qui dissimulent certaines fonctionnalités intéressantes par exemple.

Avant de vous montrer l’utilisation de GCC, le compilateur que nous allons utiliser, il faut d’abord avoir un code source sous la main. Pour créer un code source sans IDE, il faut utiliser un éditeur de texte et non pas un traitement de texte ! Un éditeur de texte est un programme qui permet de modifier des fichiers quelconques (tout est fichier, en tout cas sous les GNU/Linux et UNIX, donc avec un éditeur de texte, vous pouvez tout modifier ; cependant, on préfère un éditeur de texte pour programmer, en effet aucune personne saine d’esprit n’irait créer un fichier *.png à la main).

Un traitement de texte comme LibreOffice Writer permet non seulement de modifier des fichiers textes, mais offre la possibilité de les mettre en forme, c’est-à-dire mettre en gras du texte, changer la police, ajouter des images, etc.

Il existe des éditeurs de textes graphiques et des éditeurs de textes en console. Voici quelques-uns des plus célèbres et des plus utiles pour un programmeur :

  • Vim, tutoriel en français : commande vimtutor ;
  • Emacs, tutoriel en français disponible ici ;
  • jEdit, tutoriel en français disponible ici ;
  • Kate, tutoriel en français disponible ici.

jEdit est un très bon éditeur de texte graphique spécialement adapté à la programmation. Vim et Emacs sont des éditeurs de texte extrêmement puissants en pratique, mais assez compliqués, surtout pour un débutant ; ne les laissez pas de côté pour autant, ils ne peuvent que vous être utiles.

Maintenant, créez un fichier test.c (que vous pouvez mettre dans un dossier nommé « prog » dans votre dossier home par exemple) contenant le code suivant :

1
2
3
4
5
6
7

#include <stdio.h>

int main(void)
{
    printf("Hello world!\n");
    return 0;
}

Afin de créer l’exécutable à partir du code source précédent, on fait comme ceci :

1

gcc test.c

Ou bien encore :

1

gcc *.c   # Le joker * permet de raccourcir la commande.

Un exécutable s'est créee : a.out, que l'on lance ainsi :

1

./a.out

Pour modifier le nom de l'exécutable, on utilise l’option -o comme ceci :

1

gcc test.c -o mon_executable

Comme on peut s'en douter, il existe énormément d’options de compilation différentes, si bien qu'on ne peux pas toutes les lister ici. Cependant, ce tutoriel ainsi que vers la documentation officielle sont là pour ça. Bien que ces pages contiennent des éléments avancés du C, elles peuvent servir à n'importe quel moment, d'où l'intérêt de les garder.

Il existe bien entendu d'autres compilateurs comme Comeau C/C++, une liste exhaustive étant impossible à faire.

Mac OS

Avec un IDE

Plusieurs IDE existent sous Mac OS, par exemple Code::Blocks. Cependant, ce dernier étant assez bogué sur Mac, je vous déconseille fortement de l’utiliser. Nous allons utiliser l’IDE fourni par Apple qui se trouve être le plus complet et le plus puissant : Xcode. Il est gratuit, cependant il est en anglais (au même titre que Code::Blocks). Si vous êtes anglophobe, ne vous inquiétez pas, vous pouvez très bien vous en sortir à l'aide de volonté et d'un dictionnaire.

Premièrement, il va falloir télécharger Xcode. Si vous êtes sous Mac OS X Lion, vous n’avez qu’à aller sur le Mac AppStore (menu « Pomme > App Store ... ») et télécharger Xcode. Si vous êtes sous une version antérieure, il faudra vous rendre sur le site de développeur d’Apple : Apple Developer Connection. Il faudra ensuite vous rendre sur le Mac Dev Center puis dans « Additional download », vous cliquerez sur « View all downloads ». Quand vous aurez la liste, il suffit de chercher la version 3 de Xcode (pour Leopard et Snow Leopard) ou 2 pour les versions encore antérieures (Tiger). Vous pouvez aussi utiliser votre CD d’installation pour installer Xcode (sauf pour Lion). Seule la version 4 de Xcode sera présentée ici.

Une fois le téléchargement terminé, vous aurez un fichier nommé « Install Xcode.app », lancez l’application et cliquez sur « Install » puis acceptez les conditions d’utilisation. Votre mot de passe administrateur va vous être demandé. L’installation dure un certain temps, allez prendre un café en attendant.

Installation de xCode

Maintenant que Xcode est installé, vous pouvez supprimer le fichier « Install Xcode.app », ça vous libèrera quelques Go d’espace disque. Lancez Xcode maintenant. S’il n’est pas présent dans le dock ou si vous l’en avez supprimé par erreur, vous pourrez toujours retrouver l’application dans le menu /Developer/Applications.

Menu principal de xCode

Je pense que c’est assez explicite, pas besoin de trop m’attarder là-dessus. Cliquez sur « Create a new Xcode project », puis sélectionnez « Command Line Tool » dans la partie « Application » de « Mac OS X » sur la partie gauche puis cliquez sur « Next ».

Première page lors de la création d'un projet avec xCode

Dans le champ « Product Name », entrez simplement le nom de votre projet. Dans le champ « Company Identifier », vous pouvez mettre votre pseudo par exemple (à moins que vous apparteniez à une entreprise, dans ce cas-là, il faudrait mettre le nom de votre entreprise). Choisissez bien « C » dans le champ « Type », puis cliquez sur « Next ».

Deuxième page lors de la création d'un projet avec xCode

Dans la fenêtre suivante, vous devrez sélectionner le chemin vers lequel vous allez mettre votre projet. Xcode crée un dossier pour votre projet du nom que vous avez entré. Votre projet s’ouvre automatiquement. Vous devriez avoir une fenêtre qui ressemble à ça :

Page principale après la création d'un projet

Une petite présentation s’impose. Je vous ai entouré les 4 parties principales qui consistent l’interface, ainsi que 2 autres en haut de la fenêtre.

La partie en haut à droite, nommée « View », sert à afficher ou masquer les parties numérotées (les numéros ont été rajoutés pour bien se repérer). Vous pouvez masquer la partie 4, elle ne nous servira à rien. Il arrivera que la partie 3 ne s’affiche pas, quand vous lancez un programme qui n’affiche rien par exemple, il vous suffira de cliquer sur le bouton numéroté 3 (toujours dans la partie View). La partie 1 reste toujours visible en règle générale.

La partie en haut à gauche contient 4 boutons. Le bouton « Run » (raccourci : Cmd + R) est celui que vous utiliserez le plus souvent, il permet de compiler puis de lancer votre programme. Le bouton « ''Stop'' » permet d’arrêter votre programme à tout moment. Le bouton « Scheme » ne nous intéresse pas (il permet de changer quelques options de compilation, de changer de target, etc.). Le bouton « Breakpoints » sert à activer/désactiver les points d’arrêt. C’est utile si vous utilisez le débogueur.

Dans la partie 1, vous avez 7 onglets tout en haut. Le premier onglet sert à voir l’arborescence de votre projet (fichiers, ressources, etc.). Le 4ème onglet (en forme de point d'exclamation) sert à voir les erreurs et warnings que vous pourrez avoir pendant votre codage. Les autres onglets ne nous intéressent pas. Vous pouvez changer d’onglet en utilisant les raccourcis Cmd + 1 à 7 en fonction de l’onglet que vous voulez choisir.

La partie 2 est votre éditeur de texte (il affiche aussi les images, etc., je ne vais pas entrer dans les détails). C’est ici que vous allez placer votre code. Si vous avez plusieurs fichiers, sélectionnez le fichier dans la partie 1 et éditez-le dans la partie 2. Vous pouvez ouvrir des onglets (comme sur les navigateurs Internet) en utilisant le raccourci Cmd + T. Ce qu’il faut savoir, c’est que pendant que vous tapez votre texte, vous aurez des propositions qui vous seront faites pendant que vous tapez le mot. C'est ce qu’on appelle l’autocomplétion. Ça permet d’éviter de taper tout le mot. Pour valider, il suffit d’appuyer sur Enter ou Tab. Si vous voulez « forcer » l’apparition de l’autocomplétion, vous pouvez appuyer sur la touche Echap. Xcode vous montre vos erreurs en temps réel, ce qui vous permet de vous rendre compte de vos erreurs de syntaxe tout de suite.

La partie 3 contient la pseudo-console (partie noire de droite) et la zone de débogage (à gauche et la ligne du haut avec les flèches). Ce qui nous intéresse est la partie de droite, c’est là que vous allez exécuter vos programmes ! Pour enlever la partie de débogage, il suffit de cliquer sur le bouton juste à droite du bouton « Clear ». Vous pouvez aussi enlever les messages du débogueur (je vous le déconseille) en cliquant sur « All Output » et en sélectionnant « Target Output ».

La partie 4 ne nous intéresse pas, vous pouvez la masquer comme indiqué plus haut.

Tentons maintenant de compiler notre premier code que voici :

1
2
3
4
5
6
7

#include <stdio.h>

int main(void)
{
    printf("Hello world!\n");
    return 0;
}

Essayez de compiler ce code. Vous devriez voir s'afficher à l'écran quelque chose comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

GNU gdb 6.3.50-20050815 (Apple version gdb-1708) (Mon Aug 15 16:03:10 UTC 2011)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin". tty /dev/ttys000
sharedlibrary apply-load-rules all [Switching to process 679 thread 0x0]

Hello world! Program ended with exit code: 0

Toutes ces lignes ont été ajoutées par le compilateur automatiquement et ne nous intéressent pas vraiment. La dernière ligne affiche cependant le message Hello world! ainsi que le résultat de l'exécution.

En ligne de commande

Ça peut vous paraître étrange, mais le plus simple pour installer les outils pour la ligne de commande est d’installer Xcode. Je vous invite donc à installer Xcode comme présenté ci-dessus (et éventuellement les Xcode Tools si vous utilisez une version plus ancienne).

Une fois Xcode installé, allez dans le dossier « /Applications/Utilitaires » et lancez l’application « Terminal.app ». Pour l’éditeur de texte, vous pouvez choisir celui que vous voulez (emacs, vim, nano, etc.). Pour compiler, vous avez le choix : gcc, clang, llvm-gcc. Pour l’utilisation de tout ça, reportez-vous à la partie GNU/Linux juste au-dessus.


Rencontre avec le C

Maintenant que vous êtes parés, il est temps de découvrir le langage C à travers du code. Dans ce chapitre, nous allons nous familiariser avec la programmation en découvrant non seulement des éléments spécifiques au C, mais également des éléments communs à tous les langages de programmation. Lisez attentivement ce chapitre, il vous présentera de nombreux éléments nouveaux et du vocabulaire qui vous est peut-être inconnu.

  • Notre cible
  • Analyse du code source
  • Notions de base

Notre cible

Avant de commencer à programmer, il faut aussi définir ce que l’on va programmer, le type de programme que l’on va réaliser. Il existe en effet deux types de programmes : les programmes graphiques et les programmes en console.

Les programmes graphiques sont les plus courants et les plus connus puisqu’il n’y a pratiquement qu’eux sous Windows ou Mac OS X par exemple. Vous en connaissez énormément, peut-être sans le savoir : le lecteur de musique, le navigateur Internet, le logiciel de discussion instantanée, la suite bureautique, les jeux vidéos, ce sont tous des programmes graphiques, ou programmes GUI. En voici un exemple sous GNU/Linux :

L'éditeur graphique GIMP est un programme graphique

Cependant, écrire ce genre de programmes demande beaucoup de connaissances, il faut savoir manier des bibliothèques, connaitre plusieurs notions; bref, savoir programmer. C’est trop compliqué pour nous. Il faut donc se rabattre sur le deuxième type de programme : les programmes en console.

Les programmes console sont les premiers programmes, apparus en même temps que l’écran. Ils étaient très utilisés dans les années 1970 / 1980 (certains d’entre vous se souviennent peut-être de MS-DOS), mais ont fini par être remplacés par une interface graphique avec la sortie de Windows et de Mac OS. Cependant, ils existent toujours, et redeviennent quelque peu populaires avec GNU/Linux.

Voici un exemple de programme en console :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

12 - 7 =   5
Right!
9 - 5 =   4
Right!
1 + 5 =   6
Right!
3 + 3 =   6
Right!
5 + 4 =   9
Right!
10 + 9 =   19
Right!
16 - 10 =   6
Right!
9 - 1 =   8
Right!
18 - 10 =   8
Right!
3 + 3 =   6
Right!

Rights 10; Wrongs 0; Score 100%
Total time 16 seconds; 1.6 seconds per problem

Press RETURN to continue...

Ce sera le type de programme que nous allons apprendre à créer. Rassurez-vous, quand vous aurez fini le tutoriel, vous aurez les bases pour apprendre à utiliser d’autres bibliothèques, vous pourrez ainsi créer des programmes graphiques. Tout est possible.

Analyse du code source

Dans le chapitre précédant, nous avons installé les outils nécessaires à la compilation et nous avons compilé notre premier code source. Il est temps de découvrir plus en détail ce code et de comprendre ce qu'il signifie.

Je remets ce code ici pour que tout le monde aie le même :

1
2
3
4
5

#include <stdio.h>

int main(void)
{
}

Copiez-collez ce code pour que vous et moi ayons le même, puis sauvegardez. Même si c’est un minuscule projet, c’est une bonne habitude à prendre qui peut parfois vous épargner des problèmes de fichiers perdus.

#include

Cette ligne se situe tout en haut du programme.

1

#include <stdio.h>

C'est une directive de préprocesseur, facilement reconnaissable car elles commencent toutes par un #. Dans notre cas, elle sert à charger des fichiers qui contiennent du code tout prêt pour réaliser de nombreuses actions (comme afficher un message, récupérer des informations, quitter le programme, lire un fichier, etc). En effet, sans ces fichiers, appelés fichiers d’en-tête (en anglais, on parle de headers), le C ne sait quasiment rien faire. On dit que c'est un langage modulaire.

L’ensemble de ces fichiers d'en-tête est appelé bibliothèque (de l'anglais « library »). Le mot « librairie » est plus court, mais il n'est pas correct car il s'agit d'un anglicisme.

Dans notre cas, nous ne chargeons qu'un seul fichier d'en-tête : stdio.h, qui signifie « Standard input output », soit « Entrée-sortie standard ». Ce fichier d'en-tête va nous permettre de communiquer avec l'utilisateur en affichant des messages à l'écran et en récupérant des informations.

int main(void)

C’est le cœur du programme :

1
2
3
4

int main(void)
{

}

Ce bout de code est appelé fonction. Un programme écrit en C n’est composé pratiquement que de fonctions : c’est un bout de code qui sert à donner des instructions à l’ordinateur. Ainsi, on peut créer une fonction calculer_racine_carree qui calculera la racine carrée d’un nombre. Vous verrez plus tard dans votre apprentissage qu’un programme C est constitué d’un tas d’autres choses, mais surtout de fonctions.

Notre fonction s’appelle main (prononcez « mèïne »). C’est la fonction de base commune à tous les programmes en C, le point d’entrée du programme, son cœur. Le programme commence et finit toujours par elle. Bien sûr, nous n’écrirons pas tout le code dedans, ce serait impossible à entretenir et causeraient trop de problèmes pour de gros projets. Au contraire, elle déléguera le travail, mais on la retrouvera toujours.

Une fonction est délimitée par des accolades ({ et }). Après les accolades il n’y a rien, car pour l’instant nous n’avons que la fonction main. À noter que la dernière accolade est suivie d’une ligne vide. Elle ne sert à rien, mais il faut la mettre quand même.

À l’intérieur des parenthèses, il y a le mot « void ». Ce mot-clé signifie « Je ne veux pas de paramètres ». Il est en effet possible de donner des paramètres à la fonction main, mais ce n'est pas de notre niveau.

Notions de base

Pour l’instant, nous n’avons présenté que le code C minimal. Néanmoins, il existe des notions très importantes et communes à tous les langages de programmation, et nous allons vous les présenter, en les appliquant au C bien entendu. Soyez donc concentré, cette partie est basique, mais importante pour la suite.

Les mots-clés

Les mots-clés sont des mots spéciaux, réservés par le compilateur, que l’on ne peut pas utiliser comme on veut. Ils servent à déclarer des variables, concept que l’on découvrira dans le chapitre suivant, à préciser des attributs, et réaliser d’autres actions encore. Le C a réservé 32 mots-clés, que voici :

1
2
3
4
5
6
7
8

auto     double   int      struct
break    else     long     switch
case     enum     register typedef
char     extern   return   union
const    float    short    unsigned
continue for      signed   void
default  goto     sizeof   volatile
do       if       static   while

Norme C89 — A.1.1.2 Keywords

Nous les verrons tous au fur et à mesure de la progression de ce cours. Certains vous seront plus utiles que d’autres, mais tous ont un rôle.

D’ailleurs, ces mots-clés sont colorés par votre IDE. Cela permet au programmeur de mieux voir, et disons-le, c’est toujours plus joli que du code entièrement en noir.

Les opérateurs

Les opérateurs sont des symboles à la base des expressions (nous verrons plus bas de quoi il s’agit). Ils permettent de faire des opérations comme l’addition, la multiplication, la comparaison de deux valeurs, l’opposé d’une valeur, etc. De même qu’en mathématiques, tous n’ont pas la même priorité : certains passent avant d’autres.

Les opérateurs en C sont les suivants :

1
2
3
4
5
6

[  ]  (  )  .  ->
++  --  &  *  +  -  ~  !  sizeof
/  %  <<  >>  <  >  <=  >=  ==  !=  ^  |  &&  ||
?  :
=  *=  /=  %=  +=  -=  <<=  >>=  &=  ^=  |=
,

Norme C89 — A.1.1.6 Operators Vous noterez que sizeof est à la fois un mot-clé et un opérateur.

Les opérateurs peuvent être classés en C en sept catégories :

  • les opérateurs arithmétiques ;
  • les opérateurs d’affectation ;
  • les opérateurs logiques ;
  • les opérateurs de comparaison ;
  • l’opérateur conditionnel ;
  • les opérateurs bit-à-bit ;
  • et quelques opérateurs inclassables.

Nous examinerons toutes ces catégories au fur et à mesure que nous progresserons dans le tutoriel. Comme pour les mots-clés, vous en utiliserez certaines plus que d’autres, mais toutes ont leur utilité.

Expressions et instructions

La différence entre les deux notions est un peu subtile et conduit parfois à des confusions. Une expression est évaluée et produit un résultat. Les lignes de code suivantes sont toutes des expressions.

1
2
3

"Hello world!"
2 + 3
10 > 2

Généralement, une expression ne peut être écrite seule, sans contexte autour. Cela correspondrait en français à énoncer un sujet sans le verbe qui va derrière.

Une instruction, quant à elle, est un ordre qui permet d’exécuter telle ou telle action. Pour vous aider, chaque instruction peut se traduire par une phrase verbale en français.

1
2

printf("Hello world!");   /* Affiche « Hello world! ». */
x = 2;    /* Affecte la valeur 2 à x. */

Toutes les instructions se terminent par un point-virgule (nous apprendrons au fur et à mesure les quelques-unes qui n’en requièrent pas).

La frontière entre instruction et expression est assez floue puisqu’une instruction peut être constituée de nombreuses expressions. Le code ci-dessous est un exemple d’une instruction qui est aussi une expression.

1

x = 2 + 3;

On donne en effet un ordre à l’ordinateur (« Affecte la valeur 2 + 3 à x »), mais c’est aussi une expression qui produit la valeur 5 comme résultat. Vous verrez qu’en C, la majorité des lignes de code sont des instructions-expressions. C’est ce qui s’appelle la programmation impérative. C’est le choix des concepteurs du langage, mais ce n’est pas la seule possibilité (il en existe d’autres, mais ça ne nous concerne pas en tant qu’utilisateurs du C).

Les blocs d’instructions

Un bloc d’instructions est formé d’une suite d’instructions délimitée par des accolades, et tout ce qu’il y a entre les accolades est par conséquent à l’intérieur d’un bloc d’instructions. La fonction main est par exemple suivie d’un bloc d’instructions composé de deux instructions.

Les séparateurs

Lorsque l’on écrit, on met des espaces entre les mots pour rendre le tout plus clair. Pour la programmation, c’est pareil. On insère des espaces et des retours à la ligne dans un code source pour le rendre plus clair et plus lisible. Par exemple, les deux codes ci-dessous sont identiques pour le compilateur, mais le second est plus lisible pour le programmeur que le premier.

1
2
3

int x=0,y,rep;

int x = 0, y, rep;

Ce dernier point m’amène donc à l’indentation.

L’indentation

L’indentation est vraiment quelque chose de très important. Elle consiste en l’ajout de tabulations ou d’espaces dans un code source. Un code bien indenté est un code clair et agréable à lire. Le style d’indentation définit comment les programmeurs se débrouillent afin de faire ressortir du code.

Parce qu’un code vaut 1000 mots :

1
2
3
4
5

#include<stdio.h>
int main(void)
{ printf("Hey !\n");
  printf("Bien ?");
  return 0;}

En comparaison avec :

1
2
3
4
5
6
7
8
9

#include <stdio.h>

int main(void)
{
    printf("Hey !\n");
    printf("Bien ?");

    return 0;
}

Il existe de nombreux styles d'intendation différents. C'est à vous de choisir celui que vous préférez, et surtout de vous y tenir. Ce cours utilisera quant à lui le style Allman (ou style ANSI).

Il faut aussi que vous sachiez qu’il existe une règle concernant le nombre de colonnes (de caractères entre autres) à ne pas dépasser par ligne. C’est une très ancienne règle, qui limite le nombre de caractères par ligne à 80. Vous n’êtes pas obligé de la suivre, loin de là. Mais sachez que certains l’affectionnent encore, et ne soyez pas surpris si certains codes suivent cette règle. Dans ce tutoriel, ce ne sera pas le cas.

Les commentaires

Il est souvent nécessaire de commenter son code source pour décrire des passages un peu moins lisibles ou tout simplement pour offrir quelques compléments d’information au lecteur du code. Un commentaire est ignoré par le compilateur : il disparait et n’est pas présent dans l’exécutable. Il ne sert qu’au programmeur et aux lecteurs du code.

Un commentaire en C est écrit entre les signes /* et */ :

1

/* Ceci est un commentaire */

Il peut très bien prendre plusieurs lignes :

1
2

/* Ceci est un commentaire qui
   prend plusieurs lignes. */

Le plus dur quand on utilise les commentaires, c’est de trouver un juste milieu : trop de commentaires tue le commentaire, et bien souvent la grande majorité des commentaires sont inutiles. À l’inverse, pas assez de commentaires peuvent rendre la relecture du code plus difficile, surtout pour du code compliqué.

Pour trouver ce juste milieu, il faut savoir plusieurs choses. Premièrement, pas besoin de commenter chaque ligne : si certaines instructions sont évidentes, les commenter sera superflu. Deuxièmement, essayer de faire des blocs de commentaires donnant une explication générale plutôt que de commenter chaque ligne une à une. Les commentaires doivent servir à décrire quelque chose de flou, ne vous sentez donc pas poussés à en mettre partout.

L’idée de base est que les commentaires doivent aider le lecteur et éviter de redire ce que le code dit.

Voilà à quoi ressemblerait notre code (excessivement) commenté :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

/* Directive de préprocesseur qui permet de charger des fonctions utiles */
#include <stdio.h>

/* La fonction principale */
int main(void)
{
    /* Et des instructions */
    printf("Hello world!\n");
    return 0;
}

/* Fin du code, le programme s’achève ici */

Bien sûr, en pratique, ces commentaires sont inutiles.

En outre, vous pouvez rencontrer un autre style de commentaires, dit "commentaire de style C++" , qui provient du langage C++. Ce type de commentaire, noté // n'est autorisé en C que depuis le C99, je vous conseille donc de ne pas les utiliser en C et de les remplacer par les bon vieux /*...*/. Ces commentaires C++ commentent tout ce qui est situé entre eux et la fin de la ligne. Je les mentionne uniquement parce que vous risquez d'en voir parfois dans les codes des autres programmeurs.


Les variables

Programmer, c’est avant tout donner des ordres à notre ordinateur. Ces ordres vont permettre à notre ordinateur de faire ce qu’on veut. Notre ordinateur peut manipuler un peu de tout : du texte, de la vidéo, des nombres, etc. Les ordres qu’on va donner à notre ordinateur vont ainsi lui permettre de manipuler de l’information sous différentes formes, plus ou moins variées. À ce stade du tutoriel, on sait que ces ordres, ces instructions sont effectués par notre processeur. Mais on ne sait rien sur la façon dont notre ordinateur fait pour maintenir ces informations, ni sur comment les utiliser dans notre langage C. De même, on ne sait pas comment donner des ordres à notre ordinateur, pour qu’il fasse ce qu’on lui demande.

Ce chapitre va pallier ce problème : il vous expliquera comment manipuler les types de données les plus simples disponibles en langage C. Ceux-ci ne sont autre que des nombres et des lettres. Ils sont manipulables grâce à ce qu’on appelle des variables, qui sont l’objet de ce chapitre. Après ce chapitre, vous saurez notamment comment manipuler des nombres et des lettres en langage C. Vous pourrez ainsi profiter de votre ordinateur comme s’il s’agissait d'une grosse calculette, bien plus rapide et puissante. Néanmoins, rassurez-vous ; le niveau en maths de ce chapitre sera très très faible : si vous savez compter, vous pourrez comprendre le chapitre facilement !

Cela peut paraitre simple, et pas très intéressant. Mais il faut bien commencer par les bases, comme la manipulation de données simples : manipuler du texte ou de la vidéo est complexe, et nécessite en plus de savoir comment manipuler des nombres. Eh oui ! Comme vous allez le voir, tout est nombre pour notre ordinateur, même le texte et même la vidéo.

  • Qu’est-ce qu’une variable ?
  • Déclarer une variable
  • Utiliser des variables

Qu’est-ce qu’une variable ?

Pour comprendre ce qu’est une variable, et comment manipuler celles-ci, il faut commencer par comprendre comment notre ordinateur fait pour stocker ces informations de base.

Notre ordinateur a été conçu pour être assez polyvalent : il peut en théorie stocker tout type d’informations. Pour ce faire, celui-ci utilise une ruse particulièrement simple : il stocke ses informations en les découpant en petites unités d’information qu’on appelle des bits. Ces bits sont donc des unités très simples qui ne peuvent prendre que deux valeurs : 0 ou 1.

Pour stocker des informations plus complexes, il suffit de prendre plusieurs de ces bits et de les regrouper les uns à côté des autres. En faisant ainsi, on peut créer des suites de 0 et de 1 qui peuvent s’interpréter comme des nombres. On peut ainsi représenter des nombres positifs, des nombres négatifs, des nombres à virgule, etc. Tout ce que peut faire notre ordinateur, c’est manipuler ces suites de bits, ces nombres. En somme, notre ordinateur n’est qu’une grosse calculatrice.

Mais alors comment notre ordinateur fait pour stocker du texte, de la vidéo, etc. s’il ne sait traiter que des nombres ? Eh bien il faut savoir que les informations plus complexes, comme de la vidéo, du texte, etc. sont toutes stockées dans notre ordinateur sous la forme de nombres. En utilisant plusieurs de ces bits, on peut ainsi représenter n’importe quoi : du texte, des nombres, de la vidéo, etc. Je suppose qu’il sera difficile de me croire, mais sachez tout de même que toute information qu’on trouve dans notre ordinateur est représentée avec seulement des 0 et des 1 !

Mémoire

Ces bits sont stockés dans un composant électronique particulier, présent das notre ordinateur : la mémoire. Son rôle : stocker tous les bits qui permettent de représenter nos informations.

Enfin, je dis « la mémoire », mais en fait il y en a plusieurs. Tout comme un humain possède plusieurs mémoires (mémoire à court terme, mémoire à long terme, etc.) qui lui servent à mémoriser plein d’informations, l’ordinateur se sert aussi de plusieurs mémoires pour stocker tout un tas de données de différentes tailles.

Mais pourquoi plusieurs mémoires et pas une seule ? Le fait est que si l’on souhaitait utiliser une seule grosse mémoire dans notre ordinateur, celle-ci serait donc fatalement très lente : il est impossible de créer des mémoires qui soient à la fois rapides et qui puissent contenir beaucoup de données. On ne peut donc utiliser une seule grosse mémoire capable de stocker toutes les données dont on a besoin. Ce problème s’est posé dès les débuts de l’informatique. Les inventeurs des premiers ordinateurs modernes furent rapidement confrontés à ce problème. Pour ceux qui ne me croient pas, regardez un peu cette citation des années 1940, provenant d’un rapport de recherche portant sur un des premiers ordinateurs existant au monde :

Idéalement, nous désirerions une mémoire d’une capacité indéfiniment large telle que n’importe quelle donnée soit immédiatement accessible. Nous sommes forcés de reconnaître la possibilité de la construction d’une hiérarchie de mémoire, chacune ayant une capacité plus importante que la précédente, mais accessible moins rapidement. Burks, Goldstine, et Von Neumann

Comme on le voit, cette citation (traduite de l’anglais) montre le problème, mais évoque aussi la solution adoptée face à ce problème. Pour résoudre ce problème, il suffit de segmenter la mémoire de l’ordinateur en plusieurs sous-mémoires, de taille et de vitesse différentes qu’on utilise suivant les besoins. On aura donc des mémoires pouvant contenir peu de données dans lesquelles on pourra lire et écrire rapidement et des mémoires plus importantes, mais plus lentes. Cette solution a été la première solution inventée pour résoudre ce problème et est encore massivement utilisée à l’heure actuelle : on n’a pas encore fait mieux !

Nous avons dit que l’ordinateur utilisait plusieurs mémoires. Et il faut savoir que trois de ces mémoires sont importantes, et doivent être connues de tout programmeur. Je vous présente donc :

  • les registres ;
  • la RAM ;
  • le disque dur

Alors évidemment, ce ne sont pas les seules : on pourrait aussi citer la mémoire cache et d’autres encore, mais cela n’a rien à faire dans un tutoriel sur le C. Et puis il y a déjà des cours à ce sujet sur le Site du Zéro, citons par exemple Fonctionnement d'un ordinateur depuis zéro.

Les registres sont des mémoires intégrées dans notre processeur. Elles sont très rapides, mais ne peuvent contenir que des données très simples : on peut difficilement mettre plus qu’un nombre dedans. Leur utilité est de stocker des données temporaires afin d’y accéder plus rapidement.

La mémoire RAM est une mémoire un peu plus grosse, et plus lente que les registres. Elle peut contenir pas mal de données, et on l’utilise généralement pour stocker le programme qu’on vient de lancer, ainsi que les données qu’il va manipuler.

Cette mémoire a tout de même un léger défaut : elle perd son contenu quand on coupe le courant. Autant dire qu’on doit trouver une autre mémoire pour stocker notre système d’exploitation, nos programmes, etc. : c’est le rôle du disque dur, une mémoire très grosse, mais très lente.

En C, la mémoire la plus utilisée est la mémoire vive. Et donc, pour bien comprendre comment programmer en C, il faudra comprendre comment interagir avec cette mémoire RAM. Plus loin dans ce cours, nous verrons également comment manipuler des fichiers sur le disque dur. Mais pour ce qui est des registres, c’est autre chose : le C cache presque totalement la gestion de ceux-ci, qui est réalisée presque entièrement par le compilateur. Impossible de les manipuler directement !

La RAM

Hé, une minute : si je stocke une donnée dans ma mémoire, comment je fais pour la récupérer ? Eh bien dans ce cas-là, vous n’avez pas trop le choix : vous devez savoir où se trouve votre donnée dans la mémoire de l’ordinateur. Généralement, cette donnée se trouvera en mémoire RAM. On peut bien sûr copier notre donnée dans un registre, ou sur le disque dur, mais passons. Et pour retrouver notre donnée en RAM, rien de plus simple.

Bytes et octets

Dans notre RAM, les bits sont regroupés en « paquets » contenant une quantité fixe de bits : des « cases mémoires », aussi appelées bytes.

Généralement, nos mémoires utilisent des bytes de 8 bits. Autrefois, certaines mémoires avaient des bytes de 6 ou 5 bits, parfois plus. Mais maintenant, la situation s’est un peu normalisée et la grande majorité des mémoires utilisent des bytes de 8 bits. Un groupe de 8 bits s’appelle un octet.

Avec un octet, on peut stocker 256 informations différentes. Par exemple, on peut stocker 256 nombres différents. On peut stocker les lettres de l’alphabet, ainsi que les symboles alphanumériques. On peut aussi stocker tous les nombres de 0 à 255, ou de -128 à 127, tout dépend de comment on s’y prend.

Pour stocker plus d’informations (par exemple les nombres de -1024 à 1023), on peut utiliser plusieurs octets, et répartir nos informations dedans. Nos données peuvent prendre un ou plusieurs octets qui se suivent en mémoire, sans que cela pose problème : nos mémoires et nos processeurs sont conçus pour gérer ce genre de situations facilement. En effet, nos processeurs peuvent parfaitement aller lire 1, 2, 3, 4, etc. octets consécutifs d’un seul coup sans problème, et les manipuler en une seule fois.

Adresse mémoire

Chacun de ces octets se voit attribuer un nombre unique, l’adresse, qui va permettre de la sélectionner et de l’identifier celle-ci parmi toutes les autres. Il faut imaginer la mémoire RAM de l’ordinateur comme une immense armoire, qui contiendrait beaucoup de tiroirs (les cases mémoires) pouvant chacun contenir un octet. Chaque tiroir se voit attribuer un numéro pour le reconnaitre parmi tous les autres. On pourra ainsi dire : je veux le contenu du tiroir numéro 27 ! Pour la mémoire c’est pareil. Chaque case mémoire a un numéro : son adresse.

Adresse | Contenu mémoire
0 | 11101010
1 | 01111111
2 | 00000000
3 | 01010101
4 | 10101010
5 | 00000000

En fait, on peut comparer une adresse à un numéro de téléphone (ou à une adresse d’appartement) : chacun de vos correspondants a un numéro de téléphone et vous savez que pour appeler telle personne, vous devez composer tel numéro. Les adresses mémoires fonctionnent exactement de la même façon !

Exemple : on demande à notre mémoire de sélectionner la case mémoire d’adresse 1002 et on récupère son contenu (ici, 17).

Références

Pour retrouver votre donnée dans la RAM, on doit donc simplement préciser son adresse. Ce principe peut se généraliser aux autres mémoires : on doit fournir ce qu’on appelle une référence, qui permet d’identifier la localisation de notre donnée dans la mémoire : dans quel registre elle est (l’« adresse » du registre est alors ce qu’on appelle un nom de registre), à quel endroit sur le disque dur, etc. Ainsi, toute donnée est identifiée dans notre ordinateur par une référence, qui permet d’accéder à notre donnée plus ou moins directement. Notre adresse n’est donc qu’un cas particulier de référence, cette notion étant plus générale.

Manipuler nos données se fait alors via des références, plus ou moins compliquées, qui peuvent permettre de calculer l’adresse de notre donnée, et déterminer si elle est dans un registre, la RAM, le disque dur, etc.

Variables

Le seul problème, c’est que manipuler explicitement des références est un vrai calvaire. Si vous ne me croyez pas, essayez de programmer en assembleur, le seul langage dans lequel on doit manipuler des références explicitement. C'est une horreur. Mais rassurez-vous : on a moyen de se passer de ce genre de choses. Pour ce faire, on peut décider de camoufler ces références plus ou moins efficacement. Pour cela, on peut décider de remplacer ces références par autre chose.

Dans nos langages de programmation, et notamment dans le langage C, on remplace des références par des variables. Cette variable correspondra à une portion de mémoire, appelée objet, à laquelle on donnera un nom. Ce nom permettra d’identifier notre variable, tout comme une référence permet d’identifier une portion de mémoire parmi toutes les autres. On va ainsi pouvoir nommer les données qu’on manipule, chacun de ces noms étant remplacés par le compilateur en référence vers un registre ou vers une adresse mémoire.

Déclarer une variable

Entrons maintenant dans le vif du sujet en apprenant à déclarer nos variables. Pour bien commencer, il faut savoir qu’une variable est constituée de deux éléments obligatoires.

  • Un identificateur : c’est en gros le « nom » de la variable.
  • Un type.

Le type d’une variable permet d’indiquer ce que l’on veut stocker : un nombre entier, un nombre à virgule (on dit aussi un flottant), un caractère, etc. Pour préciser le type d’une variable, on doit utiliser un mot-clé, spécifique au type que l’on souhaite donner à notre variable.

Une fois qu’on a décidé le nom de notre variable, ainsi que son type, on peut la créer (on dit aussi la déclarer) comme ceci :

1

type identificateur;

En clair, il suffit de placer un mot-clé indiquant le type de la variable, et de placer le nom qu'on lui a choisi immédiatement après. Faites bien attention au point-virgule à la fin !

Les types

Comme dit précédemment, un type permet d’indiquer au compilateur quel type de données on veut stocker. Ce type va permettre de préciser :

  • toutes les valeurs que peut prendre la variable ;
  • et les opérations qu’on peut effectuer dessus, histoire de ne pas additionner une lettre avec un nombre à virgule.

Définir le type d’une variable permet donc de préciser son contenu potentiel et ce qu’on peut faire avec. Le langage C fournit 8 types de base :

Type | Sert à stocker
char | un caractère ou un entier
short | un entier
int | un entier
long | un entier
float | un flottant
double | un flottant
long double | un flottant

Les types short, int et long servent tous à stocker des nombres entiers qui peuvent prendre des valeurs positives, négatives, ou nulles. On dit qu’il s’agit de types signés. Pour ces trois types, il existe un type équivalent non signé. Un type entier non signé est un type entier qui n’accepte que des valeurs positives ou nulles : il ne peut pas stocker de valeurs négatives. Pour déclarer des variables d’un type non signé, il vous suffit de faire précéder le nom du type entier du mot-clé unsigned.

Le char peut lui aussi servir à stocker des nombres. Il sert surtout au stockage de caractères, mais ces derniers étant stockés dans l'ordinateur sous forme de nombres, il est possible de stocker des nombres dans un char. Le seul problème, c’est que ce char peut très bien être signé sur certains compilateurs et pas sur d’autres. Suivant le compilateur utilisé, le char sera soit signé par défaut, soit il sera non signé. Pour éviter les ennuis en utilisant un char comme un nombre, vous pouvez les déclarer explicitement non signés : unsigned char ou signés : signed char.

Capacité d’un type

Tous les types stockant des nombres (tous sauf le type char) ont des bornes, c’est-à-dire une limite aux nombres qu’ils peuvent stocker. Il faut dire que le nombre de bytes occupé par une variable d’un certain type est limité, et est généralement fixé définitivement pour toutes les variables de ce type par le compilateur. En conséquence, on ne peut pas mettre tous les nombres possibles dans une variable de type int, float, ou double. On aura forcément une valeur minimale et une valeur maximale : certains nombres seront trop grands ou trop petits pour rentrer dans une variable d’un certain type. Ces bornes (que vous pouvez trouver au paragraphe 2.2.4.2 de la norme) sont les suivantes :

Type | Minimum | Maximum
signed char | -127 | 127
unsigned char | 0 | 255
short | -32 767 | 32 767
unsigned short | 0 | 65 535
int | -32 767 | 32 767
unsigned int | 0 | 65 535
long | -2 147 483 647 | 2 147 483 647
unsigned long | 0 | 4 294 967 295
float | $-1 × 10^ | $+1 × 10^
double | $-1 × 10^ | $+1 × 10^
long double | $-1 × 10^ | $+1 × 10^

Si l'on regarde bien le tableau, on remarque que certains types ont des bornes identiques. En vérité, les valeurs présentées ci-dessus sont les minima garantis par la norme et il est fort probable qu’en réalité, vous puissiez stocker des valeurs plus élevées que celles-ci (le type int s'apparente souvent à un long, par exemple). Cependant, dans une optique de portabilité, vous devez considérer ces valeurs comme les minima et les maxima de ces types, peu importe la capacité réelle de ces derniers sur votre machine.

Information : le type unsigned int équivaut à un unsigned, ne soyez pas surpris si plus tard en lisant les codes d'autrui vous ne trouveriez seulement ce mot-clé.

Taille d’un type

Peut-être vous êtes vous demandés pourquoi existe t-il autant de types différents. La réponse est toute simple : la taille des mémoires était très limitée à l’époque où le langage C a été créé. En effet, le PDP-11 sur lequel le C a été conçu ne possédait que 24 Ko de mémoire (pour comparaison une calculatrice TI-Nspire possède 100 Mo de mémoire, soit environ 4000 fois plus). Il fallait donc l’économiser au maximum en choisissant le type le plus petit possible. Cette taille dépend des machines, mais de manière générale vous pouvez retenir les deux suites d’inégalités suivantes : char ≤ short ≤ int ≤ long et float ≤ double ≤ long double.

Aujourd’hui ce n’est plus un problème, il n’est pas nécessaire de se casser la tête sur quel type choisir (excepté si vous voulez programmer pour de petits appareils où la mémoire est plus petite). En pratique, on utilisera surtout char pour les caractères, int pour les entiers et double pour les flottants. Les autres types ne servent pas à grand-chose.

Les identificateurs

Maintenant que l’on a vu les types, parlons des identificateurs. Comme dit précédemment, un identificateur est un nom donné à une variable pour la différencier de toutes les autres. Et ce nom, c’est au programmeur de le choisir. Cependant, il y a quelques limitations à ce choix.

  • On ne peut utiliser que les 26 lettres de l’alphabet latin (majuscules ou minuscules) : pas d’accents, pas de ponctuation ni d’espaces. Le caractère underscore (« _ ») et les chiffres sont cependant acceptés.
  • Un identificateur ne peut pas commencer par un chiffre.
  • Les mots-clés ne peuvent pas servir à identifier une variable ; on ne peut donc pas utiliser ces mots :
    • auto
    • break
    • case
    • char
    • const
    • continue
    • default
    • do
    • double
    • else
    • enum
    • extern
    • float
    • for
    • goto
    • if
    • int
    • long
    • register
    • return
    • short
    • signed
    • sizeof
    • static
    • struct
    • switch
    • typedef
    • union
    • unsigned
    • void
    • volatile
    • while
  • Pour simplifier, on peut parfaitement considérer que deux variables ne peuvent avoir le même identificateur (le même nom). Il y a parfois quelques exceptions, mais cela n’est pas pour tout de suite.
  • Les identificateurs peuvent être aussi longs que l’on désire, toutefois le compilateur ne tiendra compte que des 32 premiers caractères.

Voici quelques exemples pour bien comprendre :

Identificateur correct | Identificateur incorrect | Raison
variable | Nom de variable | Espaces interdits
nombre_de_vie | 1nombre_de_vie | Commence par un chiffre
test | test! | Caractère « ! » interdit
un_dernier_pour_la_route1 | continue | Mot-clé réservé par le langage

À noter que le C fait la différence entre les majuscules et les minuscules (on dit qu’il respecte la casse). Ainsi les trois identificateurs suivants sont différents.

1
2
3

variable
Variable
VaRiAbLe

D’autres mots-clés

En plus des mots-clés qui servent à indiquer le type de notre variable, on peut utiliser d’autres mots-clés lors de la déclaration de nos variables. Le but : donner un peu plus d’informations sur nos variables. On peut ainsi préciser que l’on veut que nos variables soient constantes et non modifiables, ou d’autres choses encore. On ne va pas voir tous les mots-clés existants, et pour cause : il nous manque quelques informations pour vous expliquer le rôle de certains. Nous allons seulement parler des mots-clés const, volatile et register.

Comme vous l’avez surement deviné, ces mots-clés se placent avant le type et le nom de la variable, lors de la déclaration.

const

Le premier que je vais vous montrer est const. Ce mot-clé signifie « constant » en français. Il sert donc à déclarer une variable comme étant constante, c’est-à-dire qu’on ne pourra pas modifier sa valeur au cours du programme. Sa valeur restera donc inchangée durant toute l’exécution du programme. À quoi ça sert ? C’est utile pour stocker une variable qui ne changera jamais, comme la constante $\pi$ qui vaudra toujours 3,14159265 ou $e$ qui vaudra toujours 2,718281828.

Une recommandation qui est souvent faite est de déclarer comme étant const tout ce qui est possible. Cela permet d’éviter pas mal d’erreurs et aide à éclaircir le code. Par convention, une variable constante est souvent écrite en majuscule. Voir une variable constante en minuscule peut paraître étrange pour certains. De plus, il est extrêmement horripilant de voir des variables non constantes en majuscules !

register

Vient ensuite register. Celui-ci permet de dire au compilateur que l’on veut que notre variable soit stockée de préférence dans un registre du processeur, au lieu de devoir être placée en mémoire RAM. C’est en effet le compilateur qui décide quelle variable stocker dans les registres, durant combien de temps, et à quel moment. On dit qu’ils se chargent d’allouer les registres. register permettait autrefois d’indiquer au compilateur que la variable désignée register était à placer (ou à copier) dans les registres dès que possible.

L’utilité de register est très simple : un registre est au bas mot plus de 100 à 200 fois plus rapide que la mémoire RAM de notre ordinateur. Ainsi, placer (ou copier) une variable dans les registres permet d’accéder à celle-ci bien plus rapidement que si on devait la lire ou l’écrire depuis la mémoire RAM. Idéalement, on voudrait donc mettre toutes nos variables dans ces registres. Mais le problème, c’est que notre processeur ne possède que peu de registres. Il n’est ainsi pas rare d’avoir à se débrouiller avec seulement 4 à 8 registres. Autant dire qu’il faut alors réfléchir consciencieusement aux variables à placer dedans : il faut mettre de préférence des variables auxquelles on va accéder souvent (et ne pas hésiter à déplacer des variables entre registres et mémoire si besoin est). register permettait de préciser quelles étaient les variables à mettre en priorité dans les registres, histoire d’orienter le choix du compilateur. Cela permettait alors de rendre nos programmes plus rapides.

Mais c’était il y a longtemps : de nos jours, register ne sert plus à rien (ou presque). La raison est très simple : les compilateurs actuels disposent d’algorithmes mathématiques qui permettent de gérer les registres de façon quasi optimale. En tout cas, nos compilateurs se débrouillent nettement mieux que les programmeurs pour décider quel registre utiliser et quelles données placer dedans. Ils n’ont donc plus besoin d’aide, et register est souvent ignoré ou sous-utilisé par ces compilateurs. En clair : register est une antiquité, qui ne doit plus être utilisé, et ne sert strictement à rien. Après, libre à vous de tenter de l’utiliser, mais au moins, vous savez d’avance que c’est inutile.

volatile

Le dernier est moins connu, car moins utilisé : il s’agit de volatile. C’est un peu l’inverse de register. Une variable marquée volatile ne peut pas être copiée ou placée dans les registres du processeur.

volatile sert dans certains cas bien particuliers, que vous ne rencontrerez surement jamais. Il arrive qu’une variable soit modifiée par autre chose que le programme dans lequel on a déclaré cette variable. Par exemple, certaines variables peuvent être modifiées par des périphériques, comme la souris, le clavier, etc. Ou encore, on peut avoir à manipuler des variables accessibles par plusieurs programmes, qui peuvent être mises à jour à n’importe quel moment. Ces modifications de la variable se feront alors en mémoire RAM : il est en effet impossible pour un périphérique ou un programme d’aller modifier le contenu d’un registre déjà attribué à un autre programme.

Si on stocke notre variable dans un registre, les mises à jour effectuées en mémoire RAM ne seront pas répercutées sur la copie de la variable stockée dans les registres. Le programme qui aura stocké cette variable dans ses registres continuera donc de manipuler une variable périmée, non mise à jour. Cela peut donner lieu à des bugs relativement bizarres ou catastrophiques.

Pour éviter toute catastrophe, ces variables spéciales doivent donc être marquées volatile, histoire de ne pas pouvoir être placées dans les registres du processeur, et lues ou écrites en mémoire RAM.

Je tiens à préciser que volatile n’est toutefois utile que pour certaines variables, potentiellement accessibles par autre chose que le programme qui l’a déclaré (un autre programme, un périphérique, etc.), et qui peuvent être modifiées n’importe quand. Ce genre de cas est très rare, et n’arrive que quand on doit travailler avec du matériel très spécial, ou qu’on veut créer des programmes très compliqués, qui manipulent directement le matériel, comme des pilotes de périphériques des systèmes d’exploitation, etc. Autant être franc, vous n’aurez certainement jamais à utiliser volatile dans un programme, tellement ce genre de cas est rare et particulier. Mais un peu de culture générale ne fait jamais de mal, et peut être utile au cas où.

Déclaration et initialisation

Maintenant que nous savons toutes les bases, entrainons-nous à déclarer quelques variables :

1
2
3
4

double taille;
volatile unsigned int age;
char caractere;
short petite_valeur;

On peut aussi déclarer plusieurs variables de même type sur une même ligne, en séparant leurs noms par une virgule :

1

int age, taille, nombre;

Je vous conseille d’utiliser les deux méthodes de déclaration que vous venez de voir (multiligne et monoligne) simultanément, comme ceci :

1
2
3

int annee, mois, jour;
int age, taille;
int x, y, z;

J’ai regroupé les déclarations de variables selon les « rapports » qu’ils ont entre eux.

Je vous présente du code, des explications, encore du code puis encore des explications. Mais durant tout ce temps, vous avez peut-être essayé de compiler ces codes. Êtes-vous surpris de voir qu’il ne se passe rien ? Les plus malins d’entre vous auront peut-être compris qu’il ne se passe rien en apparence. Je dis bien en apparence car, en réalité, l’ordinateur fait parfaitement son travail : il va réserver des cases mémoire pour nos variables. Votre ordinateur fait donc tout ce que vous lui demandez de faire : déclarer des variables, et non modifier leurs valeurs et encore moins les afficher !

Alors OK, notre case mémoire est réservée pour notre variable, mais quelle est la valeur qu’il y a dedans (quel est l’objet dans le tiroir) ? Eh bien en fait, c’est indéterminé. Il peut y avoir n’importe quelle valeur (n’importe quel objet dans le tiroir).

Initialisation

Mais heureusement, on peut donner une valeur à une variable dès sa déclaration. On dit aussi qu’on initialise notre variable. Ainsi on est sûr que la case mémoire ne contient pas n’importe quoi.

Pour initialiser une variable, on procède ainsi si c’est une variable destinée à contenir une valeur numérique :

1

type identificateur = valeur;

Ou comme ceci si c’est un caractère :

1

char identificateur = 'lettre';

Voici quelques exemples de déclarations de variables :

1
2
3
4

volatile unsigned int age = 25;
short petite_valeur = 1;
const long abc = 3141596;
char caractere = 'h';

Petite note sur const : il faut donner une valeur à la variable dès la déclaration puisque l’on ne pourra plus la modifier après !

Petite précision : la norme C89 réclame que l’on sépare les déclarations du reste du code : on ne peut pas déclarer une variable où l’on veut. Si l’on veut vraiment suivre la norme, on déclare d’abord toutes les variables en début de bloc (c’est-à-dire après une accolade ouvrante) et ensuite vient le reste des instructions.

Initialisation des nombres flottants

Je tiens à retenir votre attention sur la manière d’initialiser les variables flottantes (soit donc de type float ou double). En fait, ces variables sont faites pour contenir des nombres à virgule. À l’initialisation, il ne faut donc pas se contenter de donner sa valeur, il faut aussi mettre la « virgule ». Sauf que l’on ne met pas une virgule : on met un point.

1

const double pi = 3.14;

Cela vient du fait que le C est une invention américaine, et que les anglophones utilisent le point à la place de la virgule, on met un point là où nous autres francophones mettons une virgule.

Et vous devez impérativement mettre ce point, même si vous voulez stocker un nombre entier dans un float ou un double. Par exemple, vous ne devez pas écrire double a = 5; mais double a = 5.; (certains préfère double a = 5.0;, cela revient au même). Si vous ne le faites pas, vous risquez d’avoir quelques problèmes.

Voici un petit tableau récapitulatif afin de bien comprendre :

Type | Initialisation
char | 0 ou '\0'
short | 0
int | 0
long | 0
float | 0.
double | 0.
long double | 0.

Affectation

Nous savons donc déclarer (créer) nos variables, et les initialiser (leur donner une valeur à la création). Il ne nous reste plus qu’à voir la dernière manipulation possible : l’affectation. Cette affectation permet de modifier la valeur contenue dans une variable, pour la remplacer par une autre valeur.

Il va de soi que cette affectation n’est possible que pour les variables qui ne sont déclarées avec const : par définition, de telles variables sont en effet constantes et ne peuvent voir leur contenu changer. Cela interdit toute affectation pour ces variables déclarées constantes.

Pour faire une affectation, il suffit d’opérer ainsi :

1

identificateur = nouvelle_valeur;

On voit que la syntaxe est similaire à celle d’une déclaration avec initialisation : la seule différence, c’est qu’on n’a pas à préciser le type. Ce type est en effet fixé une fois pour toutes lors de la déclaration de notre variable : pas besoin de le préciser lors d’une affectation. Si je veux changer la valeur de mes variables, je procède tout simplement comme suit.

1
2
3

age = 30;
taille = 177.5;
petite_valeur = 2;

Il n’y a aucune limite, voyez par exemple :

1
2
3
4
5
6
7

petite_valeur = 2;
petite_valeur = 4;
petite_valeur = 8;
petite_valeur = 16;
petite_valeur = 8;
petite_valeur = 4;
petite_valeur = 2;

À chaque affectation, la variable va prendre une nouvelle valeur. Par contre, ne mettez pas le type quand vous voulez changer la valeur, sinon vous aurez le droit à une belle erreur du type « redefinition of 'nom_de_votre_variable' » car vous aurez créé deux variables avec le même identificateur !

Le code suivant est donc incorrect :

1
2

int age = 15;
int age = 20;

Si vous exécutez tous ces codes, vous verrez qu’ils n’affichent toujours rien. Mais pourquoi ? Tout simplement parce qu’on n'a pas demandé à notre ordinateur d'afficher quoique ce soit. Et ce n'est pas pour tout de suite : on apprendra comment faire pour afficher quelque chose sur la console au chapitre suivant. Quoiqu'il en soit, ne soyez pas pressés et prenez bien le temps d’assimiler toutes les notions présentées dans ce chapitre.

Utiliser des variables

Nous savons désormais déclarer, affecter et initialiser une variable, mais que diriez-vous d’apprendre à réaliser des opérations dessus ? Il est en effet possible de réaliser des calculs sur nos variables, comme les additionner, les diviser voire même des opérations plus complexes. C’est le but de cette sous-partie. Nous allons donc enfin transformer notre ordinateur en grosse calculette programmable ! Cette partie est importante, donc même si vous détestez les mathématiques, vous devez la lire.

Calculs de base

Le langage C fournit 5 opérations de base sur nos variables.

  • L'addition +.
  • La soustraction -.
  • La multiplication *.
  • La division /.
  • Le modulo % (le reste d’une division euclidienne).

Le langage C fournit aussi d’autres fonctions mathématiques préprogrammées, mais qu’on ne peut pas encore utiliser à ce stade. Nous devrons donc reporter à plus tard l’utilisation de fonctions mathématiques plus complexes. Si jamais vous devez utiliser une fonction mathématique plus complexe, il faudra la programmer, pour le moment.

Commençons par détailler ces 5 opérations de base.

Addition, soustraction et multiplication

C’est tout simple : pour faire notre calcul, il suffit d’assigner le résultat du calcul à une variable :

1
2
3
4
5

int somme, difference, produit;

somme = 2 + 3;   /* 5 */
difference = 8 - 12;   /* -4 */
produit = 6 * 7;   /* 42 */

On pourrait multiplier les exemples à l’infini, je pense néanmoins que vous avez compris le concept.

Division

La division en informatique est différente de celle en mathématiques. Si je vous dis $\frac{15}{4}$, vous en déduisez que le quotient est 3,75. Pourtant, le résultat de celle-ci est 3 en langage C.

1

int division = 15 / 4;

Suite à la lecture du chapitre suivant, vous pourrez vérifier cette affirmation en affichant la valeur de cette opération, mais vous n’en êtes pas encore là, et je vous défends de vous y rendre avant la fin de ce chapitre.

Pour l’ordinateur, le résultat de 15 / 4 est bien 3. Pourquoi ? Parce qu’on lui a demandé de faire une division d’entiers (appelée division euclidienne), donc il répond par des entiers. Si l’on veut afficher le résultat complet (à nos yeux), il faut l’indiquer à l’ordinateur. Comment faire ? Essayez de trouver la solution tout seul.

Un indice ? Pensez aux flottants.

La solution :

1

double division = 15. / 4.;   /* même si pour nous, c’est la même chose que 15 / 4 */

Même si pour nous c’est intuitif, pour l’ordinateur il faut bien préciser si ce sont des entiers ou des flottants.

Modulo

Le modulo est un peu le complément de la division puisqu’il permet de connaitre le reste d’une division euclidienne. C’est une opération de base aux yeux de l’ordinateur, même si elle est assez peu connue. Un petit code pour la route :

1

int modulo = 11 % 4;

Ici, le résultat de cette instruction est 3, car $11 = 2 \times 4 + 3$. Le modulo est la réponse au problème de la division d’entiers.

Opérations entre variables

Le principe est tout simple : au lieu d’opérer sur des nombres, on opère sur des variables. Ainsi on peut faire les mêmes opérations sur des variables :

1
2

var = var1 + var2;
d = c / b * a;

On peut ainsi rajouter autant de variables que l’on veut, et même mélanger avec des nombres :

1

d = c / b * a - s + 7 % 2

Cependant, il faut faire attention à la priorité des opérateurs : comme en mathématiques, certains opérateurs passent avant d’autres : * / % ont une priorité supérieure à + -.

Dans ce code :

1

x = nombre + y * 4;

C’est y * 4 qui sera exécuté d’abord, puis on ajoutera nombre au résultat. Faites donc attention, sous peine d’avoir de mauvaises surprises. Dans le doute, mettez des parenthèses.

Les raccourcis

Comment vous y prendriez-vous pour multiplier une variable par trois ? La solution « naïve » serait de faire :

1

variable = variable * 3;

Cependant, c’est long, fatigant, et peut vite devenir fastidieux si l'on fait beaucoup d’opérations de ce genre. On a donc inventé des techniques permettant de raccourcir notre code : les raccourcis. Ces raccourcis fonctionnent pour toutes les opérations arithmétiques de base. Ainsi, pour faire la même opération que le code précédent, on peut raccourcir ainsi :

1

variable *= 3;

Ce code concis marche exactement comme le précédent. Et le principe est valable pour toutes les opérations, pour n’importe quelle valeur :

1
2
3
4

variable += 2;
variable -= 9;
variable /= 8;
variable %= 4;

Cependant, il existe encore deux autres raccourcis très fréquemment utilisés.

Incrémentation et décrémentation

Ce sont deux opérations qui, respectivement, ajoute ou enlève 1 à une variable. On pourrait utiliser les raccourcis vus juste avant :

1
2

variable += 1;
variable -= 1;

Cependant, on a inventé de nouveaux raccourcis pour ces deux opérations, car elles sont très utilisées (vous comprendrez vite pourquoi dans les prochains chapitres). Ces deux raccourcis sont les suivants :

1

variable++;

Pour l’incrémentation (on ajoute 1) et :

1

variable--;

pour la décrémentation (on enlève 1). Ces deux lignes sont parfaitement équivalentes aux deux premières. Elles permettent simplement de raccourcir le code. Ce que je viens de vous montrer s’appelle l’[in/dé]crémentation postfixée. En effet, il est aussi possible de faire une [in/dé]crémentation pré-fixée : le signe est alors avant la variable et non après :

1
2

++variable;
--variable;

Il y a une subtile différence entre les deux formes. Une [in/dé]crémentation pré-fixée change la valeur de l’expression avant d'envoyer la valeur, alors qu’une [in/dé]crémentation post-fixée renvoie la valeur et la modifie ensuite. Petit exemple pour bien comprendre : si j'ai une variable a qui vaut 5, ++a incrémentera immédiatement la variable a, qui vaudra alors 6.

1
2

int a = 5 ;
int b = ++a ; /* ici, b vaudra 6 */

Par contre, a++ attendra avant d'incrémenter la variable : celle-ci sera incrémentée après la prochaine "utilisation".

1
2
3

int a = 5 ;
int b = a++ ; /* ici, b vaudra 5, et a sera mit à 6 une fois que
               la valeur de a++ est recopiée dans b */

Fondamentalement, utiliser l’une ou l’autre des deux formes ne change pas grand chose, sauf dans quelques cas particuliers. Dans la plupart des cas, les programmeurs utilisent la forme postfixée (i++) en permanence. Il n'y a pas vraiment de raisons valables pour faire cela à l'heure actuelle, mais cette pratique est monnaie courante. Aussi, ne soyez pas étonné de voir des codes utilisant la forme post-fixée alors qu'une forme préfixée aurait été plus adéquate.

Les conversions de type

La conversion de type est une opération qui consiste à changer le type d’une variable en un autre. Je peux ainsi convertir une variable de type float en type int, par exemple. Il existe deux types de conversions : les conversions explicites et les conversions implicites.

Les conversions explicites

Ce sont des conversions voulues et demandées par le programmeur. Elles se déclarent en suivant ce modèle :

1

(<Type>) <Expression>

Voici par exemple un code où l’on demande explicitement la conversion d’un double en int.

1
2
3
4

int a;
const double pi = 3.14;

a = (int) pi;

La valeur de pi reste inchangée, elle vaudra toujours 3.14 dans la suite du programme. Par contre, a vaut maintenant 3, puisque le flottant a été converti en entier. Expliciter une conversion peut nous servir quand on veut forcer le résultat d’une opération par exemple. Imaginons que nous voulons faire une division, mais que les deux opérandes soient de type int.

1
2
3
4
5
6
7

int a, b;
double c;

a = 5;
b = 9;

c = a / b;

Vu qu’on fait une division euclidienne, le résultat sera tronqué. Si on veut avoir un résultat avec la partie décimale, il suffit de faire une conversion explicite d’un des deux opérandes en double :

1
2
3

c = (double) a / b;
/* ou */
c = a / (double) b;

Les conversions implicites

Ce sont des conversions que fait le compilateur tout seul, sans que l’on ait demandé quoi que ce soit. En général, ça ne gêne pas le programmeur, mais ça peut parfois être problématique si la conversion n’était pas voulue. Par exemple, si l’on reprend le code précédent, il y aura toujours une conversion.

1
2
3
4
5
6

int a;
const double pi = 3.14;

/* Il y a conversion implicite de double en int, mais rien
n’est précisé, c’est le compilateur qui fait ça de lui-même. */
a = pi;

Cependant, les conversions amènent parfois à des pertes d’information si l’on n’y prend pas garde.

Perte d’information

Une perte d’information survient quand on convertit le type d’une variable en un autre type plus petit et que celui-ci ne peut pas contenir la valeur reçue. Si, par exemple, je convertis un double de 100 chiffres en un short, il y a perte d’information, car le type short ne peut pas contenir 100 chiffres. La règle à retenir est la suivante :

Si on convertit un type T vers un type S plus petit, il y a perte d’information. -- Règle des conversions

Les conversions, et surtout les conversions implicites qui peuvent être vicieuses, doivent être manipulées avec précaution, au risque de tomber sur des valeurs fausses en cas de perte d’information. Nous découvrirons d’ici quelques chapitres comment connaitre la taille d’un type T pour éviter ces pertes d’information.


Manipulations basiques des entrées/sorties

Durant l’exécution d’un programme, le processeur, qui est le cerveau de l’ordinateur, a besoin de communiquer avec le reste du matériel. Il doit en effet recevoir des informations pour réaliser des actions et il doit aussi en transmettre. Ces échanges d’informations sont les entrées et les sorties (ou input / output pour les anglophones), souvent abrégée E/S (ou I/O par les anglophones).

Les entrées permettent de recevoir une donnée en provenance de certains périphériques. Les données fournies par ces entrées peuvent être une information envoyée par le disque dur, la carte réseau, le clavier, la souris, un CD, un écran tactile, bref par n’importe quel périphérique.Par exemple, notre clavier va transmettre des informations sur les touches appuyées (par exemple 1 et 8) au processeur ou à la mémoire : notre clavier est donc une entrée.

À l’inverse, les sorties vont transmettre des données vers ces périphériques. On pourrait citer l'exemple de l'écran : notre ordinateur lui envoie des informations pour qu'elles soient affichées.

Dans ce chapitre, nous allons apprendre différentes fonctions fournies par le langage C, qui vont nous permettre de recevoir ou d'envoyer des informations sur nos sorties et d'en recevoir sur nos entrées. Vous saurez ainsi comment demander à un utilisateur de rentrer une information au clavier, et comment afficher quelque chose sur la console.

  • Les sorties
  • Interagir avec l'utilisateur
  • Exercice

Les sorties

Intéressons-nous dans un premier temps aux sorties. Afin d’afficher un caractère ou même un texte (on préfère le terme de « chaîne de caractères ») à l’écran, il faut utiliser des fonctions. Depuis votre premier code, vous en utilisez une : la fonction main. Une fonction, en simplifiant un peu, est un morceau de code exécutant des instructions. Des instructions qui permettent d’effectuer des opérations (avec par exemple des fonctions mathématiques) sur des variables ou encore d’écrire du texte à l’écran par exemple.

Nous allons voir trois fonctions d’affichage de données dans ce chapitre, je nomme fièrement :

  • printf pour écrire une chaîne de caractères formatée ;
  • puts pour écrire une chaîne de caractères toute simple ;
  • putchar pour écrire un caractère.

printf — Écrire une chaîne de caractères de manière formatée

La fonction printf affiche donc une chaîne de caractères (c'est à dire du texte) à l’écran. On l'utilise comme ceci :

1

printf("Votre texte...");

Cette fonction permet non seulement d'afficher des chaînes de caractères simples, mais également la valeur d'une variable passée en paramètre. Pour ce faire, il suffit d'utiliser un indicateur de conversion : il s'agit du caractère spécial % suivi d'une lettre qui varie en fonction du type de la variable. Voici les indicateurs de conversions de la norme C89 :

Type | Indicateurs de conversions
char | %c
int | %d
long | %ld
short | %hd
float | %f
double | %f
long double | %Lf
unsigned int | %u
unsigned short | %hu
unsigned long | %lu

Après avoir inscrit un indicateur de conversion dans la chaîne de caractère (entre les guillemets), il faut indiquer de quelle variable il faut afficher la valeur. Il suffit de rajouter une virgule après les ces derniers, suivis du nom de la variable, comme ceci :

1

printf("%[lettre]", variable_a_afficher);

Essayez donc d’afficher la valeur de la variable pi :

1
2
3
4

/* Cette variable est constante (utilisation de « const »)
car la valeur de pi ne change jamais */
const double pi = 3.1415926536;
printf("pi = %f", pi);

1

pi = 3.1415926536

C’est tout simple. Vous pouvez répéter ces opérations autant que fois que vous le voulez, il suffit de rajouter le symbole %[lettre] à chaque fois que vous souhaitez afficher la valeur d’une variable. Il n’y a aucune limite, voyez par exemple :

1
2
3
4
5
6
7

double flottant = 19.75;
int nombre = 10, nombre_2 = 0;
char lettre = 'a';

nombre_2 = nombre * 3;

printf("nombre = %d, nombre_2 = %d, flottant = %f, lettre = %c", nombre, nombre_2, flottant, lettre);

Amusez-vous à créer des variables et à en afficher la valeur pour voir si vous avez bien compris. Attention cependant, vous devez préciser la variable dont il faut afficher la valeur, sinon vous pouvez aller des valeurs fantaisistes au plantage du programme !

Le scoop du jour

Devinez quoi, vous pouvez effectuer toutes sortes d’opérations à l’intérieur même de printf , comme des calculs par exemple. Quelques codes pour bien comprendre :

1
2
3
4
5
6
7

printf("%d | ", 1 + 2 * 3); /* Affiche « 7 » */

printf("%d | ", (1 + 2) * 3); /* Affiche « 9 » */

printf("%f | ", -1. + 2. * 4. / 3.); /* Affiche « 1.666667 » */

printf("%c", 'x'); /* Affiche « x » */

1

7 | 9 | 1.666667 | x

Faites bien attention à mettre le « . », dans le cas contraire, le résultat serait faussé. Nous pouvons faire sensiblement la même chose avec des variables :

1
2
3

int x = 42, y = 21;

printf("%d", x * y / 2);

1

441

Tabulations et compagnie

Afin d’afficher une tabulation ou encore un retour à la ligne, on utilise un caractère d'échappement.

1

printf("La valeur de la variable\n\tx est : %f\n\ty = %d", x, y);

1
2
3

La valeur de la variable
        x est : 42.424340
        y = 1

'\n' et '\t' font partie de ces caractères. Voici un petit tableau qui en liste quelques-uns parmi les plus utilisés :

Caractère d’échappement | Signification
'\n' | Retour à la ligne
'\t' | Tabulation horizontale
'\r' | Retour chariot
'\f' | Saut de page
'\'' | Affiche une apostrophe
'\"' | Affiche un guillemet
'\\' | Affiche un antislash
'%%' | Affiche un %

Précision

Si vous avez été attentifs, vous avez dû remarquer que lorsqu'on affiche un flottant il y a un certain nombre de zéros qui suivent, et ce peu importe s'ils sont utiles ou non. Heureusement, les programmeurs de la fonction printf ont pensé à tout. Afin de supprimer certains zéros inutiles, vous pouvez préciser la précision de l’affichage. Une précision, sous la forme d’un point ('.') suivi par un nombre, indique donc le nombre de chiffres qu’il y aura derrière la virgule.

1
2
3

double x = 42.42734;

printf("%.2f", x);

1

42.43

On remarque dans cet exemple un arrondi au centième. Il correspond au nombre indiqué lors de la précision de l'affichage. La variable n'a pas été modifiée, l'arrondi n'intervient que pour l'affichage.

Pour finir, voici la chaîne de format (simplifiée) qu’utilise la fonction printf :

1

% [.précision] indicateur de conversion

Sur plusieurs lignes

Plutôt qu'appeler plusieurs fois la fonction printf pour écrire du texte, on peut ne l'appeler qu'une fois et écrire plusieurs lignes. Pour cela, on utilise le signe \ à chaque fin de ligne. Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

#include <stdio.h>

int main(void)
{
    printf("Texte ecrit sur plusieurs \
           lignes dans le code source \
           et egalement dans la console.");

    return 0;
}

1
2

Texte écrit sur plusieurs                  lignes dans le code source
   et également dans la console.

L'inconvénient est que le texte affiché dans la console n'est pas parfaitement alignés. Il existe heureusement une autre possibilité : on peut écrire plusieurs phrases entre guillemets sans problème :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

#include <stdio.h>

int main(void)
{
    printf("Texte ecrit sur plusieurs "
           "lignes dans le code source "
           "mais sur une seule dans la console.");

    return 0;
}

1

Texte écrit sur plusieurs lignes dans le code source mais sur une seule dans la console.

Ce qu'il faut retenir de ces deux méthodes, c'est que l'on est pas obligé d'appeler systématiquement printf pour afficher de nouvelles phrases, mais qu'au contraire il est possible d'en afficher plusieurs en n'utilisant qu'une fois la fonction.

puts — Écrire une chaîne de caractères

L’utilisation de la fonction puts est plus simple puisque elle ne se contente d'afficher que des chaînes de caractères simples.

1

puts("Salut les zeros !");

Cette fonction n’a pas de chaîne de format à évaluer comme printf, elle est donc plus simple à utiliser ainsi que (très légèrement) plus rapide à l’exécution. Ainsi, le code suivant ne fonctionnera pas :

1
2
3

int var = 0;

puts("%d", var);

Je tiens à préciser que puts ajoute automatiquement une fin de ligne à la fin de la chaîne de caractères que vous souhaitez afficher.

putchar — Écrire un caractère

La fonction putchar affiche tout simplement un caractère.

1

putchar('c');

On peut également afficher une variable de type char avec cette fonction.

1
2
3

char caractere = 'Z';

putchar(caractere);

Interagir avec l'utilisateur

Maintenant que nous savons déclarer, utiliser et même afficher des variables, nous sommes fin prêts pour interagir avec l’utilisateur. En effet, jusqu’à maintenant, on s’est contentés d’afficher des informations. Nous allons voir comment en récupérer grâce à la fonction scanf, dont l'utilisation est assez semblable à printf.

1

scanf("%[lettre]", &variable_dans_laquelle_on_va_mettre_notre_valeur);

Souvenez-vous de la brève explication sur la mémoire au début du chapitre précédent. Celle-ci, je vous le rappelle, fonctionne comme une armoire avec des tiroirs (les adresses mémoires) et des objets dans ces tiroirs (nos variables). La fonction scanf a besoin de connaitre l’emplacement en mémoire de nos variables afin de les modifier. Afin d’effectuer cette opération, on utilise le symbole &. Ce concept de transmission d’adresses mémoires est un petit peu difficile à comprendre au début, ne vous inquiétez pas ; vous aurez l’occasion de bien revoir tout cela en profondeur dans le chapitre des pointeurs.

Vous pouvez tester ce programme :

1
2
3
4
5
6
7

int age;

puts("Donnez votre age :");
/* Si vous oubliez le &, le programme plantera quand vous le lancerez,
car vous tenterez d’accéder à une adresse mémoire inexistante ! */
scanf("%d", &age);
printf("Vous avez %d an(s) !\n", age);

1
2
3

Donnez votre age :
15
Vous avez 15 an(s) !

Ici, scanf attend patiemment que l’utilisateur saisisse un nombre au clavier afin de modifier la valeur contenue à l’adresse de age : on dit que c'est une fonction bloquante, puisqu'elle suspend l'exécution du programme tant que l'utilisateur n'a rien rentré. Ensuite, printf affiche bien ce qui est voulu.

Les indicateurs de conversions sont peu différents de ceux de printf :

Type | Affichage | Exemple
char | %c | char lettre; scanf("%c", &lettre);
int | %d | int age; scanf("%d", &age);
long | %ld | long age; scanf("%ld", &age);
short | %hd | short age; scanf("%hd", &age);
float | %f | float taille; scanf("%f", &taille);
double | %lf | double taille; scanf("%lf", &taille);
long double | %Lf | long double taille; scanf("%Lf", &taille);
unsigned int | %u | unsigned int age; scanf("%u", &age);
unsigned short | %hu | unsigned short age scanf("%u", &age);
unsigned long | %lu | unsigned long age; scanf("%u", &age);

Vous pouvez utiliser cette fonction de différentes manières, vous pouvez lire plusieurs entrées en même temps, par exemple :

1
2
3
4

int x, y;

scanf("%d %d", &x, &y);
printf("x = %d | y = %d\n", x, y);

L’utilisateur a deux possibilités, soit d’insérer un espace, soit d’insérer un retour à la ligne :

1
2
3
4
5
6
7
8

14
6
x = 14 | y = 6

/* OU ENCORE */

14 6
x = 14 | y = 6

La fonction scanf est en apparence simple, oui, je dis bien en apparence, car son utilisation peut devenir très complexe en sécurisant les entrées de l’utilisateur, par exemple. Cependant, à votre niveau, vous ne pouvez pas encore gérer le cas où l’utilisateur entre du texte à la place d’un nombre ; si cela arrive, votre programme aura de très fortes chances de planter. Ce n’est pas très grave, vous saurez en temps voulu comment gérer de manière avancée les entrées de l’utilisateur.

Exercice

Vous êtes prêts pour un exercice ? Essayez de coder une minicalculatrice qui :

  • Dit bonjour ;
  • Demande deux nombres entiers à l’utilisateur ;
  • Les additionne, soustrait, multiplie et les divise (avec un arrondi au millième) ;
  • Dit au revoir.

Voici ce que ça pourrait donner :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

Bonjour !

Veuillez saisir le premier nombre : 4
Veuillez saisir le deuxième nombre : 7

Calculs :

        4 + 7 = 11
        4 - 7 = -3
        4 * 7 = 28
        4 / 7 = 0.571

Au revoir !

Essayez vraiment de réaliser ce petit programme sans aide, sans regarder le code de correction. Si besoin est, elle est ici.

Vous y êtes arrivé sans problème ? Bravo ! Dans le cas contraire, ne vous inquiétiez pas, ce n'est pas grave. Relisez bien tous les points qui ne vous semblent pas clairs et ça devrait aller mieux.


Les conditions

Jusque-là, vous ne savez qu'écrire du texte, manipuler des nombres et interagir un tout petit peu avec l'utilisateur.

En gros, pour le moment, un programme est quelque chose de sacrément stupide : il ne permet que d'exécuter des instructions dans l'ordre. Pour le moment, on ne sait faire que cela : faire des calculs simples dans un certain ordre. Notre ordinateur ne sert pas à grand chose de plus qu'une vulgaire calculette qu'on peut acheter n'importe où. Mine de rien, il serait sympathique de pouvoir faire plus de choses. Mais rassurez-vous : on peut faire mieux ! Les langages de programmation actuels fournissent des moyens permettant à notre programme de faire des choses plus évoluées et de pouvoir plus ou moins s'adapter aux circonstances au lieu de réagir machinalement. Pour rendre notre ordinateur "plus intelligent", on peut par exemple souhaiter que celui-ci n'exécute une suite d'instructions que si une certaine condition est remplie. Ou faire mieux : on peut demander à notre ordinateur de répéter une suite d'instructions tant qu'une condition bien définie est respectée.

Pour ce faire, diverses structures de contrôle de ce type ont donc été inventées. Voici les plus utilisées et les plus courantes : ce sont celles qui reviennent de façon récurrente dans un grand nombre de langages de programmation actuels. On peut bien sûr en inventer d’autres, en spécialisant certaines structures de contrôle à des cas un peu plus particuliers ou en en inventant des plus évoluées.

Nom de la structure de contrôle | Ce qu'elle fait
If...Then | exécute une suite d'instructions si une condition est respectée.
If...Then...Else | exécute une suite d'instructions si une condition est respectée ou exécute une autre suite d'instructions si elle ne l'est pas.
Switch | exécute une suite d'instructions différente suivant la valeur testée.
While...Do | répète une suite d'instructions tant qu'une condition est respectée.
Do...While | répète une suite d'instructions tant qu'une condition est respectée. La différence, c'est que la boucle Do...While exécute au moins une fois cette suite d'instructions.
For | répète un nombre fixé de fois une suite d'instructions.

Concevoir un programme (dans certains langages de programmation), c'est simplement créer une suite d'instructions, et utiliser ces fameuses structures de contrôle pour l'organiser.

Ces structures de contrôle permettent donc de modifier le comportement du programme suivant la valeur de différentes conditions. Ainsi, si une condition est vraie, alors le programme se comportera d'une telle façon, si elle est fausse, le programme fera telle ou telle chose, etc. Notre ordinateur a donc besoin de deux choses pour exécuter ces structures de contrôle :

  • des instructions pour évaluer ces conditions ;
  • et des instructions qui vont faire reprendre notre processeur au bon endroit, en fonction du résultat de notre condition.

Les première sont ce qu'on appelle des instructions de test. Les secondes sont ce qu'on appelle des instructions de branchement conditionnelles.

Dans ce chapitre, on va voir comment utiliser les structures de contrôles les plus basiques disponibles en C, à savoir les trois premières structures de contrôle mentionnées dans le tableau du dessus. Nous allons aussi voir comment faire tester si une condition est vraie ou fausse à notre ordinateur.

  • Conditions et booléens
  • Les opérateurs logiques
  • La structure if
  • L'instruction switch
  • L'opérateur ternaire

Conditions et booléens

Il va de soit qu'avant de pouvoir utiliser des structures de contrôle, on doit pouvoir exprimer, écrire des conditions. Pour cela, le langage C fournit de quoi écrire quelques conditions de base. Divers opérateurs existent en C : ceux-ci permettent d'effectuer des comparaisons entre deux nombres. Ces opérateurs peuvent s'appliquer sur deux nombres écrits en dur dans le code, ou deux variables qui stockent un nombre. Ces opérateurs vont donc effectuer des comparaisons entre deux nombres, et vérifier si la comparaison est vraie ou fausse. Par exemple, ces opérateurs permettront de vérifier si une variable est supérieure à une autre, si deux variables sont égales, etc.

Comparaisons

L'écriture d'expression avec des opérateurs est similaire aux écritures mathématiques que vous voyez en cours : l'opérateur est entre les deux variables à comparer. On a donc une variable à gauche de l'opérateur, et une à droite.

Pour donner un exemple, on va prendre l'opérateur de supériorité : l'opérateur >. Avec cet opérateur, on pourra écrire des expressions du style : a > b , qui vérifiera si la variable a est strictement supérieure à la variable b.

Mais cet opérateur n'est pas le seul. Voici un tableau réunissant ses collègues :

Symbole en langage C | Signification
== | Est-ce que les deux variables testées sont égales ?
!= | Est-ce que les deux variables testées sont différentes ?
< | Est-ce que la variable à gauche est strictement inférieure à celle de droite ?
<= | Est-ce que la variable à gauche est inférieure ou égale à celle de droite ?
> | Est-ce que la variable à gauche est strictement supérieure à celle de droite ?
>= | Est-ce que la variable à gauche est supérieure ou égale à celle de droite ?

Ces opérateurs ne semblent pas très folichons : avec, on ne peut faire que quelques tests de conditions basiques sur des nombres. Mais pour un ordinateur, tout est nombre, et on peut donc se débrouiller avec ces opérateurs pour exprimer toutes les conditions que l'on veut : il suffira des les combiner entre eux, avec les bonnes valeurs à comparer. Vous verrez quand on passera à la pratique, cela sera plus clair.

Dans les faits, le processeur de notre ordinateur possède souvent des instructions pour effectuer ces comparaisons. Vous pouvez parfaitement considérer que ces comparaisons peuvent être calculées directement par le processeur.

Les booléens

Comme je l'ai dit, ces opérateurs vont avoir un résultat : vrai si la condition est vérifiée, et faux si la condition est fausse. Mais notre ordinateur ne connait pas vrai ou faux : il ne connait que des suites de bits, des nombres ! Et on est alors obligé de coder, de représenter les valeurs "vrai" ou "faux" avec des nombres.

Certains langages fournissent pour cela un type bien séparé pour stocker le résultat des opérations de comparaisons. La représentation des valeurs "vrai" et "faux" est ainsi gérée par le compilateur, et on peut travailler dans notre code en utilisant à la place des valeurs True (vrai en anglais) ou False (faux). Mais dans les premières versions du langage C, ce type spécial n'existe pas ! Il a donc fallu ruser et trouver une solution pour représenter les valeurs "vrai" et "faux". Pour cela, on a utilisé la méthode la plus simple : on utilise directement des nombres pour représenter ces deux valeurs. Ainsi, la valeur Faux est représentée par un nombre entier, tout comme la valeur Vrai. Le langage C impose que :

  • Faux soit représenté par un zéro ;
  • et que Vrai soit représenté par tout entier différent de zéro.

Et nos opérations de comparaisons suivent cette règle pour représenter leur résultat. Ainsi, une opération de comparaison va renvoyer 0 si elle est fausse et renverra 1 si elle est vraie. Les opérateurs de comparaisons vérifient l'existence d'une certaine relation entre les valeurs qui lui sont associées (opérandes). Le résultat de cette vérification est égal à l'une de ces deux valeurs : 0, si la condition est logiquement fausse et 1 si en revanche elle est vraie.

Exemple

Vous ne me croyez pas ? Alors, vérifions quels sont les résultats renvoyés par diverses comparaisons. Par exemple, essayons avec le code suivant :

1
2
3
4
5
6
7
8
9

int main(void)
{
    printf("10 == 20 renvoie %d\n", 10 == 20);
    printf("10 != 20 renvoie %d\n", 10 != 20);
    printf("10 < 20 renvoie %d\n", 10 < 20);
    printf("10 > 20 renvoie %d\n", 10 > 20);

    return 0;
}

Le résultat :

1
2
3
4

10 == 20 renvoie 0
10 != 20 renvoie 1
10 < 20 renvoie 1
10 > 20 renvoie 0

Le résultat confirme bien ce que je vous ai dit ci-dessus : les résultats de ces conditions sont soit 0 si la comparaison effectuée est fausse, soit 1 si elle est vraie.

Les opérateurs logiques

Toutes ces comparaisons sont un peu faibles seules : il y a des choses qui ne sont pas possibles en utilisant une seule de ces comparaisons.

Par exemple, on ne peut pas vérifier si un nombre est dans un intervalle en une seule comparaison. Supposons que pour une raison quelconque, je veuille vérifier que le contenu d'une variable de type int est compris entre 0 et 1000, 0 et 1000 non inclus. Je ne peux pas vérifier cela avec une seule comparaison (ou alors il faut vraiment ruser). On peut vérifier que notre entier est inférieur à 1000 OU qu'il est supérieur à zéro, mais pas les deux en même temps. Il nous faudrait donc trouver un moyen de combiner plusieurs comparaisons entre elles pour résoudre ce problème. Eh bien rassurez-vous : le langage C fournit de quoi combiner plusieurs résultats de comparaisons, plusieurs booléens. Il fournit pour cela ce qu'on appelle des opérateurs booléens, aussi appelés des opérateurs logiques.

Les opérateurs logiques de base

Il existe trois opérateurs logiques. L'opérateur ET, l'opérateur OU, et l'opérateur NON. Les opérateurs ET et OU vont permettre de combiner deux booléens. L'opérateur NON ne servira par contre pas à cela, comme vous allez le voir. Voyons plus en détail ces trois opérateurs.

L'opérateur ET

L'opérateur ET va manipuler deux booléens. Il va renvoyer "vrai" si les deux booléens sont vrais, et renverra faux sinon.

Premier booléen | Second booléen | Résultat
Faux | Faux | Faux
Faux | Vrai | Faux
Vrai | Faux | Faux
Vrai | Vrai | Vrai

Il permet par exemple de vérifier que deux comparaisons sont vraies en même temps : il suffit d'appliquer un opérateur ET sur les booléens renvoyés par les deux comparaisons pour que notre opérateur ET nous dise si les deux comparaisons sont vraies en même temps. Cet opérateur s'écrit &&. Il s'intercalera entre les deux comparaisons ou booléens à combiner. Par exemple, reprenons l'exemple vu plus haut, avec l'intervalle. Si je veux combiner les comparaisons a > 0 et a < 1000, je devrais écrire ces deux comparaisons entièrement, et placer l'opérateur ET entre les deux. Ce qui fait que l'expression finale sera a > 0 && a < 1000.

L'opérateur OU

L'opérateur OU fonctionne exactement comme l'opérateur ET : il prend deux booléens et les combines pour former un résultat. La différence c'est que l'opérateur OU ne vérifie pas que les deux booléens vrais en même temps. À la place, il va vérifier si un seul des booléens qu'il manipule est vrai. Si c'est le cas, il renverra "vrai". Dans les autres cas, il renverra "Faux".

Premier booléen | Second booléen | Résultat
Faux | Faux | Faux
Faux | Vrai | Vrai
Vrai | Faux | Vrai
Vrai | Vrai | Vrai

Cet opérateur s'écrit ||. Il s'intercalera entre les deux booléens ou les deux comparaisons à combiner. Pour donner un exemple, supposons que je veuille savoir si un nombre est divisible par 3 ou par 5, ou les deux. Pour cela, je vais devoir utiliser deux comparaisons : une qui vérifie si notre nombre est divisible par 3, et une autre qui vérifie s’il est divisible par 5. Ces conditions sont a % 3 == 0 pour le test de divisibilité par 3, et a % 5 == 0 pour le test de divisibilité par 5. Il reste juste à combiner les deux tests avec l'opérateur ||, ce qui donne : ( a % 3 == 0 ) || ( a % 5 == 0 ). Vous remarquerez que j'ai placé des parenthèses pour plus de lisibilité.

L'opérateur NON

Cet opérateur est un peu spécial : il va manipuler un seul booléen, contrairement à ses confrères ET et OU. Son rôle est d'inverser ce dernier.

Booléen | Résultat
Faux | Vrai
Vrai | Faux

Cet opérateur se note ! . Son utilité ? Simplifier certaines expressions. Par exemple, si je veux vérifier qu'un nombre n'est pas dans l'intervalle $] 0 , 1000 [$, on peut utiliser l'opérateur NON intelligemment. Je sais vérifier qu'un nombre est dans cet intervalle : il me suffit d'écrire l'expression a > 0 && a < 1000, vu plus haut. Pour rappel, cette expression permet de vérifier qu'un nombre est dans cet intervalle. Pour vérifier la condition inverse, à savoir "le nombre a n'est pas dans cet intervalle", il suffit d'appliquer l'opérateur NON à cette expression. On obtient alors l'expression ! ( a > 0 && a < 1000 ).

Vous remarquerez que pour cet exemple, on peut se passer de l'opérateur NON en récrivant une expression plus légère, à savoir a <= 0 || a >= 1000. C'est ainsi, on peut simplifier les expressions écrites avec des opérateurs logiques pour diminuer le nombre d'opérateurs utilisés. Cela sert pour simplifier l'écriture des calculs, ou gagner marginalement en performances lors des calculs des expressions utilisant ces opérateurs. Pour la culture générale, ces techniques de simplification servent aussi dans divers domaines de l’informatique, et même en électronique. Pour les curieux, il existe un tutoriel sur le sujet sur le Site du Zéro, accessible via ce lien : l'algèbre de Boole.

Évaluation en court-circuit

Dans les opérateurs logiques && et ||, on exécute obligatoirement la première comparaison avant la seconde. C'est toujours le cas : la norme du C impose de tester d'abord la première comparaison, puis d'effectuer la seconde. Ce n'est pas le cas dans d'autres langages, mais passons.

Ce genre de détail permet à nos opérateurs && et || d'avoir un comportement assez intéressant, qui peut être utile dans certains cas pour éviter des calculs inutiles. Pour l'illustrer, je vais reprendre l'exemple utilisé plus haut : on veut vérifier qu'un entier est compris entre 0 et 1000 (0 et 1000 ne comptent pas, ne sont pas inclus dans l'intervalle voulu). On utilise pour cela l'expression logique a > 0 && a < 1000. Et c'est là que les choses deviennent intéressantes. Supposons que a soit inférieur ou égal à zéro. Dans ce cas, on saura dès la première comparaison que notre entier n'est pas dans l'intervalle. On n'a pas besoin d'effectuer la seconde. Eh bien rassurez-vous : le langage C nous dit si jamais la première comparaison d'un && ou d'un || suffit à donner le bon résultat, la seconde comparaison n'est pas calculée.

Par exemple, pour l'opérateur &&, on sait d'avance que si la première comparaison est fausse, la seconde n'est pas à calculer. En effet, l'opérateur ET ne renvoie vrai que si les deux comparaisons sont vraies. Si une seule d'entre elles renvoie faux, on pas besoin de calculer l'autre. Et vu que la première comparaison, celle de gauche, est celle effectuée ne premier, on est certain que si celle-ci renvoie faux, la seconde n'est pas calculée.

Pour l'opérateur ||, c'est différent : la seconde comparaison n'est pas calculée quand la première comparaison est vraie. En effet, l'opérateur || renvoie vrai dès qu'une seule des deux comparaisons testées est vraie. Donc, si la première est vraie, pas besoin de calculer la seconde.

Ce genre de propriétés des opérateurs && et || peut-être utilisée efficacement pour éviter de faire certains calculs. Il suffit de choisir intelligemment quelle comparaison mettre à gauche de l'opérateur, suivant la situation.

Encore mieux !

Bien sûr, on peut aussi mélanger ces opérateurs pour créer des conditions encore plus complexes. Voici un exemple d'une expression logique plutôt complexe (et inutile, je l'ai créé uniquement pour l'exemple) :

1
2
3
4
5
6
7

int nb_1 = 3, nb_2 = 64, nb_3 = 12, nb_4 = 8, nb_5 = -5, nb_6 = 42;

int boolean = ((nb_1 < nb_2 && nb_2 > 32) ||
               (nb_3 < nb_4 + nb_2 || nb_5 == 0)) &&
               (nb_6 > nb_4);

printf("La valeur logique est egale a : %d\n", boolean);

Ici, la variable boolean est égale à 1, la condition est vrai. Comme vous le voyez, j'ai inséré des retours à la ligne pour la clarté du code.

Parenthèses

En regardant le code écrit plus haut, vous avez surement remarqué la présence de plusieurs parenthèses : celles-ci enlèvent toute ambigüité dans les expressions créées avec des opérateurs logiques. Et oui, en mathématiques on doit utiliser des parenthèses dans nos équations; et bien c'est pareil avec des expressions utilisant des opérateurs logiques. Par exemple, le code suivant :

1

printf( "%d\n", (a && b) || (c && d) );

Est différent de :

1

printf( "%d\n", a && (b || c) && d );

Pour être sûr d'avoir le résultat souhaité, ajoutez des parenthèses.

La structure if

Vous savez désormais manipuler les booléens, cela est certes très amusant, mais l'intérêt de la chose limité est assez limité pour le moment. Ils nous permettent de savoir si une ou plusieurs conditions sont respectées, mais reste à trouver un moyen pour exécuter un bloc d'instruction suivant le résultat de ces conditions. C'est le rôle de l'instruction if et de ses consœurs.

L'instruction if

L'instruction if sert à exécuter un bloc d'instructions si une expression logique ou une condition est vérifiée ou passe à la suite du programme si ce n'est pas le cas.

L'instruction if ressemble à ceci :

1
2
3
4

if(/* Expression logique */)
{
    /* Une ou plusieurs instructions */
}

Si la condition testée par le if n'est pas vérifiée, le bloc d'instruction est zappé et le programme recommence immédiatement à la suite du bloc d'instruction délimité par l'instruction if.

Si vous n'avez qu'une seule instruction à réaliser, vous avez la possibilité de ne pas mettre d'accolades.

1
2

if(/* Expression logique */)
    /* Une seule instruction */

Cependant, je vous conseille de mettre les accolades systématiquement afin de rendre vos codes plus clairs et de ne pas vous poser de problèmes si vous décidez de rajouter des instructions par la suite en oubliant d'ajouter des accolades. Bien sûr, ce n'est qu'un avis personnel, vous êtes libre de faire ce que vous voulez.

Exemple numéro 1

Faisons un test :

1
2
3
4
5
6

int nombre_1 = 10, nombre_2 = 20;

if(nombre_1 < nombre_2)
{
    printf("%d est inferieur a %d\n", nombre_1, nombre_2);
}

La sortie de ce code est évidemment :

1

10 est inférieur à 20

L'instruction if évalue l'expression logique nombre_1 < nombre_2, conclue qu'elle est valide, et exécute le bloc d'instruction.

Exemple numéro 2

Maintenant, on va regarder ce qui se passe quand la condition testée dans notre if n'est pas valide.

Essayez le code suivant :

1
2
3
4
5
6

int nombre_1 = 10, nombre_2 = 20;

if(nombre_1 > nombre_2)
{
    printf("%d est superieur a %d\n\n", nombre_1, nombre_2);
}

Aucune surprise, puisque nombre_1 est inférieur à nombre_2. La condition est fausse et le bloc d'instruction du if n'est pas exécuté.

L'instruction else

Avec notre instruction if, on sait exécuter un bloc d'instruction quand une condition est remplie. Mais c'est tout. Si l'on veut exécuter un bloc d'instruction alternatif dans le cas où la condition ne serait pas remplie, on doit rajouter une autre instruction if à la suite.

1
2
3
4
5
6
7
8
9

if (a > 5)
{
    /* du code */
}

if (a <= 5)
{
    /* code alternatif */
}

Le seul problème, c'est qu'on doit rajouter un if et évaluer une nouvelle condition. Ce n'est pas très efficace et assez long à taper. Pour limiter les dégâts, le C fournit une autre instruction : l'instruction else, qui signifie "sinon". Celle-ci se place immédiatement après le bloc d'instruction d'un if. Elle permet d’exécuter un bloc d'instruction alternatif si la condition testée dans le if n'est pas remplie. Sa syntaxe est la suivante :

1
2
3
4
5
6
7
8
9

if(/*Expression logique*/)
{
    /*Une ou plusieurs instructions*/
}

else
{
    /*Une ou plusieurs instructions*/
}

Et elle doit être comprise comme ceci :

L'instruction else ne possède aucune parenthèse, pensez-y lorsque vous programmez.

Exemple

Passons maintenant à la pratique : les exemples, il n'y a que cela de vrai. Supposons que je veuille créer un programme très simple, auquel on fourni une heure, et qui indique si il fait nuit ou jour à cette heure-ci. On suppose qu'il fait jour de 9 heures à 20 heures, et qu'il fait nuit sinon. J'obtiendrais ce résultat :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

int main(void)
{
    int heure;
    scanf("%d", &heure);

    if(heure > 8 && heure < 20)
    {
        printf("Il fait jour.\n");
    }
    else
    {
        printf("Il fait nuit.\n");
    }

    return 0;
}

If / else if

Nos instructions if et else sont très utiles. Et lorsqu'on les utilise, il arrive parfois que l'on imbrique plusieurs instructions if ou else les unes dans les autres. Ainsi, de tels codes sont possibles :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

if (condition)
{
    /* du code */
}

else
{
    /* plusieurs instructions */

    if (autre condition)
    {
        /* du code */
    }
}

Ces codes sont assez longs à écrire, et de telles imbrications sont assez difficiles à lire quand beaucoup de if et de else sont imbriqués. Pour éviter ces inconvénients, on a inventé une instruction qui permet de simplifier l'écriture de certaines de ces imbrications. Il s'agit de l'instruction else if. Les imbrications simplifiables avec un else if sont celles qui s'écrivent comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

if(/*Expression logique*/)
{
    /*Une ou plusieurs instructions*/
}

else
{
    if(/*Expression logique*/)
    {
        /*Une ou plusieurs instructions*/
    }
}

Faites bien attention : le bloc d'instruction du else doit contenir un if, éventuellement avec un else, mais rien d'autre. Celles-ci peuvent alors être simplifiées comme suit :

1
2
3
4
5
6
7
8
9

if(/*Expression logique*/)
{
    /*Une ou plusieurs instructions*/
}

else if(/*Expression logique*/)
{
    /*Une ou plusieurs instructions*/
}

Tout se passe comme si on avait enlevé les accolades du else. De cette description, on peut facilement déduire qu'une instruction else if suit toujours un if ou un autre else if. Notre instruction else if va s’exécuter si le bloc d'instruction placé juste au-dessus de lui ne s’exécute pas. Si c'est le cas, il testera sa condition, et s'exécutera si celle-si est valide.

Petite remarque : si on place une instruction if suivie de plusieurs else if (le tout éventuellement terminé par un else final), un seul bloc d'instruction sera exécuté : celui pour lequel la condition testée est vraie. Notre ordinateur va ainsi tester les conditions du if et des différents else if, jusqu'à tomber sur la bonne, et exécuter le bloc d'instruction qui correspond. Voyez par exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

int main(void)
{
    int heure = 11;

    if(heure >= 7 && heure <= 12)
    {
        printf("On est le matin !\n");
    }
    else if(heure >= 9 && heure <= 12)
    {
        printf("Le petit dejeune, c'est fini !\n");
    }
    else if(heure < 7 || heure > 12)
    {
        printf("On est soit l'aprem', soit le soir, soit la nuit.\n");
    }

    return 0;
}

1
2

11
On est le matin !

Un petit exercice pour bien comprendre

Imaginez que vous ayez un score de jeu vidéo sous la main :

  • Si le score est inférieur à 2000, afficher « C'est la catastrophe ».
  • Si le score est supérieur ou égal à 2000 et que le score est inférieur à 5000, afficher « Tu peux mieux faire ».
  • Si le score est supérieur ou égal à 5000 et que le score est inférieur à 9000, afficher « Tu es sur la bonne voie ».
  • Sinon, afficher « Tu es le meilleur ! ».

À vous de codez ça. Si vous n'y arrivez pas, ce n'est pas grave, relisez simplement ce chapitre autant de fois que nécessaire. Voici la correction, regardez seulement après avoir essayé de faire l'exercice :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

int main(void)
{
    int score;

    printf("Quel est le score du joueur : ");
    scanf("%d", &score);

    if(score < 2000)
    {
        printf("C'est la catastrophe\n");
    }

    else if(score >= 2000 && score < 5000)
    {
        printf("Tu peux mieux faire \n");
    }

    else if(score >= 5000 && score < 9000)
    {
        printf("Tu es sur la bonne voie\n");
    }

    else
    {
        printf("Tu es le meilleur !\n");
    }

    return 0;
}

L'instruction switch

L'instruction switch permet de comparer successivement une variable à une ou plusieurs valeurs (ou comparants). Si la variable concorde avec telle ou telle valeur, on exécute la ou les instructions qui suivent. Cette instruction se présente sous la forme suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

switch(/* variable */)
{
    case /* comparant_1 */ :
        /* Instructions */
        break;

    case /* comparant_2 */ :
        /* Instructions */
        break;

    /* Etc... */

    default: /* Si aucune comparaison n'est juste */
        /* Instruction(s) à exécuter dans ce cas */
        break;
}

Ce qu'il faut retenir, c'est que switch compare une variable à une liste de comparants. Chaque comparant est défini à l'aide d'un case. Si la comparaison est vraie, alors le switch exécute toutes les instructions jusqu'au prochain break (ce mot-clef permet de mettre fin à une itération, nous verrons cela plus en détail dans le prochain chapitre). Si aucune comparaison n'est bonne, alors ce sont les instructions associées au default qui seront exécutées. Voici un exemple dans lequel on compare la variable note à 0, 1, 2, 3, 4 et 5 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

int main(void)
{
    int note;

    printf("Quelle note as-tu obtenu : ");
    scanf("%d", &note);

    switch(note)
    {
        /* si note == 0 */
        case 0:
            puts("No comment.");
            break;

        /* si note == 1 */
        case 1:
            puts("Cela te fait 4/20, c'est accablant.");
            break;

        /* si note == 2 */
        case 2:
            puts("On se rapproche de la moyenne, mais ce n'est pas encore ca.");
            break;

        /* si note == 3 */
        case 3:
            puts("Tu passes.");
            break;

        /* si note == 4*/
        case 4:
            puts("Bon travail, continue ainsi !");
            break;

        /* si note == 5 */
        case 5:
            puts("Excellent !");
            break;

        /* si note est différente de 0, 1, 2, 3, 4 et 5 */
        default:
            puts("Euh... tu possedes une note improbable.");
            puts("Vivement le prochain chapitre qui permettra de demander a l'utilisateur de refaire son choix !");
            break;
    }

    return 0;
}

Comme dit précédemment, si la condition est vraie, le switch exécute toutes les instructions jusqu'au prochain break. Cela permet de faire des combinaisons comme celle-ci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

int main(void)
{
    int note;

    printf("Quelle note as-tu obtenu : ");
    scanf("%d", &note);

    switch(note)
    {
        /* si la note est comprise entre 0 et 4 inclus */
        case 0:
        case 1:
        case 2:
        case 3:
        case 4:
            printf("Tu n'as pas la moyenne.\n");
            break;

        /* si au contraire la note est égale ou supérieure à 5 */

        case 5:
        case 6:
        case 7:
        case 8:
        case 9:
        case 10:
            printf("Tu as la moyenne.\n");
            break;

        default:
            printf("Erreur : note impossible\n");
            break;
    }

    return 0;
}

L'opérateur ternaire

L'opérateur ternaire, qui est une autre façon de faire un test de condition, tient son nom du fait qu'il est le seul à avoir trois opérandes. En effet, il se compose comme suit :

1

(condition) ? instruction si vrai : instruction si faux

Les parenthèses ne sont pas obligatoires. Ce qu'il y a à retenir, c'est qu'il se comporte comme un if / else, tout en étant plus condensé et plus rapide à écrire. Voyez par vous-mêmes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    int heure;

    scanf("%d", &heure);

    (heure > 8 && heure < 20) ? printf("Il fait jour.") : printf("Il fait nuit.");

    return 0;
}

Il est également possible de l'écrire sur plusieurs lignes, même si cette pratique est moins courante :

1
2
3

(heure > 8 && heure < 20) ?
    printf("Il fait jour.")
    : printf("Il fait nuit.");

Les ternaires peuvent sembler inutiles, surtout que s'ils sont mal employés ils rendent un programme moins lisible. Cependant, l'exercice suivant va vous prouvez que quand on les emploie bien, ce sont de bons alliés.

Exercice

Pour bien comprendre cette nouvelle notion, nous allons faire un petit exercice. Imaginez qu'on veuille faire un mini jeu vidéo dans lequel on affiche le nombre de coups du joueur. Seulement voilà, vous êtes maniaques du français et vous ne supportez pas qu'il y ait un 's' en trop ou en moins. Essayez de coder un programme dans lequel on demande à l'utilisateur le nombre de coups puis on affiche le résultat.

Fini ? Voici la correction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    int nb_coups;

    printf("Donnez le nombre de coups : ");
    scanf("%d", &nb_coups);

    printf("\nVous gagnez en %d coup%c", nb_coups, nb_coups > 1 ? 's' : ' ');
    return 0;
}

Ce programme utilise les ternaires pour condenser l'expression et aller plus vite dans l'écriture du code. Sans les ternaires il aurait fallu faire quelque chose comme :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

#include <stdio.h>

int main(void)
{
    int nb_coups;

    printf("Donnez le nombre de coups : ");
    scanf("%d", &nb_coups);

    printf("\nVous gagnez en %d coup", nb_coups);

    if (nb_coups > 1)
        putchar('s');
    else
        putchar(' ');

    return 0;
}


Les boucles

Ce chapitre est la suite du précédent puisque nous allons aborder ici les boucles. Une boucle est un moyen de répéter des instructions selon une condition. Ces structures, dîtes itératives, que nous allons voir dans ce chapitre sont les suivantes :

Nom de la structure de contrôle | Ce qu'elle fait
Boucle While...Do | répète une suite d'instructions tant qu'une condition est respectée.
Boucle Do...While | répète une suite d'instructions tant qu'une condition est respectée. La différence, c'est que la boucle Do...While exécute au moins une fois cette suite d'instructions.
Boucle For | répète un nombre fixé de fois une suite d'instructions.
  • La boucle while
  • La boucle do-while
  • La boucle for
  • Branchements inconditionnels
  • Exercices

La boucle while

La première des boucles que nous allons étudier est la boucle while (qui signifie "tant que"). Celle-ci permet de répéter un bloc d'instruction tant qu'une condition est remplie.

Syntaxe

La syntaxe de notre boucle while est assez simple, comme vous pouvez en juger :

1
2
3
4

while (/* expression booleenne */)
{
    /* bloc d'instruction à répéter */
}

Exemple

Je vous propose un petit exemple complètement inutile qui va illustrer tout ça :

1
2
3
4
5
6
7

int i = 0;

while (i < 5)
{
    printf("La variable i vaut %d\n", i);
    i++;
}

Dans cet exemple, on utilise une variable nommée i, mais on aurait très bien pu l’appeler compteur. Cependant, les programmeurs sont faignants, c'est pourquoi ils appellent généralement leurs variables i lorsqu'elles sont couplées avec les boucles (cela s'apparente presque à une convention de nommage). Mais pourquoi i en particulier ? Nous verrons cela quelques chapitres plus loin. En attendant, voici ce que ce code affiche à l'écran :

1
2
3
4
5

La variable i vaut 0
La variable i vaut 1
La variable i vaut 2
La variable i vaut 3
La variable i vaut 4

Le fonctionnement est simple à comprendre :

  • Au tout départ, notre variable i vaut 0. Étant donné que 0 est bien inférieur à 5, la condition est vraie, donc on rentre dans la boucle.
  • On affiche la valeur de i.
  • On incrémente i, qui vaut maintenant 1.
  • On recommence la boucle.

Ces étapes vont ainsi se répéter pour 1, 2, 3 et 4. Quand la variable i vaudra 5, la condition sera fausse, et le while ne sera alors pas exécuté.

Boucles infinies

Le point vital dans les boucles, c'est qu'elles puissent se terminer : si elles n'ont pas de quoi s'arrêter, elles s'exécuteront à l'infini ! Soyez donc très vigilants ! Par exemple, observez le code suivant :

1
2
3
4
5
6
7

unsigned int i = 0;

/* Ce qui équivaut à while(1) <--> Toujours vrai */
while(i >= 0)
{
    printf("La variable i vaut %d\n", i);
}

Affiche dans la console sans jamais s'arrêter :

1
2
3
4

La variable i vaut 0
La variable i vaut 0
La variable i vaut 0
...

Le code continuera jusqu'à ce que l'utilisateur arrête le programme. C'est ce qu'on appelle une boucle infinie. Les boucles infinies peuvent être utiles s'il y a une condition d'arrêt (une touche sur laquelle on appuie, un numéro à rentrer, etc) ; c'est d'ailleurs sur ce principe que fonctionnent de nombreux programmes comme les jeux vidéos : tant que l'utilisateur ne demande pas de quitter, on reste dans la boucle qui contient toutes les instructions du jeu.

Exercice

Pour bien faire rentrer ce qu'on a vu plus haut, rien ne vaut un petit exercice. On va vous demander de créer un morceau de code qui vérifie si un nombre est premier, et affiche le résultat à l'écran. Ce nombre, ce sera une variable nommée number, qui aura été déclarée précédemment dans notre code, mais vous pouvez aussi la saisir au clavier avec un scanf. Bref, peu importe.

Nous allons vous donner quelques détails. Tout d'abord, on sait qu'un nombre x n'est pas divisible par un nombre y si x % y est différent de zéro. Ensuite, vous allez devoir utiliser une boucle while, et quelques structures de contrôles annexes. Et enfin, pour rappel, un nombre premier est divisible uniquement par 1 et par lui-même. Et maintenant, à vos claviers !

Ça y est, vous avez fini ? Alors votre code doit sûrement ressembler à quelque chose dans le genre :

[secret]{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>

int main(void)
{
    int nombre, i = 2;

    puts("nombre = ");
    scanf("%d", &nombre);

    while ((i < nombre) && (nombre % i != 0))
    {
        ++i;
    }

    if (i == nombre)
    {
        puts("nombre est premier");
    }

    else
    {
        puts("nombre n'est pas premier");
    }

    return 0;
}

}

Les plus malins qui ont un bon niveau en mathématique savent déjà qu'il y a moyen de faire mieux que ce code naïf. On peut en effet utiliser de nombreux théorèmes et diverses astuces pour savoir si un nombre est premier. La correction présentée au-dessus va en effet prendre beaucoup de temps à vérifier qu'un nombre est premier et fait beaucoup de calculs inutiles. Par exemple, on n'est pas obligé de vérifier si le nombre* est divisible par des nombres supérieurs à sa racine carrée.

La boucle do-while

La deuxième boucle que nous allons voir est similaire à la première : il s'agit de la boucle do while.

Fonctionnement

Notre boucle do while fonctionne comme la boucle while, à un petit détail prêt : une boucle do while s'exécutera toujours au moins une fois, alors qu'une boucle while peut ne pas s'exécuter.

Syntaxe

En terme d'écriture, la première différence avec while est qu'ici le test de la condition se fait à la fin de la boucle et non au début. La deuxième chose notable est la présence d'un point-virgule tout à la fin. Il est obligatoire de le mettre, sinon la compilation échouera.

Exemple

Voici le même code que pour la boucle while, mais écrit avec une boucle do while :

1
2
3
4
5
6
7
8

int i = 0;

do
{
    printf("La variable i vaut %d\n", i);
    ++i;

} while (i < 5);

Cet exemple affiche la même chose que le code utilisé comme exemple pour la boucle while :

1
2
3
4
5

La variable i vaut 0
La variable i vaut 1
La variable i vaut 2
La variable i vaut 3
La variable i vaut 4

Autre exemple

Comme je l'ai dit plus haut, notre boucle do while s'éxecute au moins une fois. Pour vous le prouver, voici un exemple :

1
2
3
4
5
6
7
8
9

int main(void)
{
    do
    {
        puts("Boucle do-while");
    } while (0);

    return 0;
}

Et ce code affiche pourtant ceci à l'écran :

1

Boucle do-while

Pourquoi ? Vous vous souvenez du chapitre précédant on nous avions vu les booléens ? On avait dit que 0 signifiait faux. Ainsi, le while ne s'exécute pas puisque la condition est fausse, alors que le do while s'exécute puisqu’il affiche bien « Boucle do-while », alors que la condition est là encore fausse. Ce code prouve bien qu'une boucle do while s'exécute toujours au moins une fois.

La boucle for

La troisième et dernière boucle que nous allons voir est la boucle for. Celle-ci permet de répéter une suite d'instruction un certain nombre de fois, ce nombre de fois étant fixé par un ou plusieurs compteurs.

Fonctionnement

Une boucle for se décompose en trois parties.

  • L'initialisation du compteur : on prépare le compteur en l'initialisant à la valeur que l'on veut (le plus souvent c'est 0).
  • La condition : comme pour les deux autres boucles, il faut une condition. Tant qu'elle est vraie, la boucle s'exécute ; elle se stoppe dès qu'elle devient fausse.
  • La modification du compteur : cette étape modifie le compteur. Même s'il s'agit le plus souvent d'une incrémentation, on peut utiliser toutes les opérations que l'on veut.

Petit détail : l'initialisation se fait une seule fois, avant de rentrer dans la boucle, et non à chaque tour de boucle. Par contre, la modification du compteur et le test de la condition ont lieu à chaque tour de boucle.

Une boucle for peut être vue comme une sorte de spécialisation d'une boucle while. En fait, une boucle for est strictement équivalente à ce code :

1
2
3
4
5
6
7
8

Initialisation du compteur ;

while ( condition ou expression booléenne )
{
    Instructions de la boucle ;

    Modification du compteur ;
}

Syntaxe

La syntaxe de cette boucle est assez différente des deux autres, mais elle n'en est pas moins utile. Pour utiliser une telle boucle, on doit procéder comme ceci :

1
2
3
4

for (initialisation du compteur ; condition ; modification du compteur)
{
    Instructions;
}

Attention à bien déclarer le compteur avant la boucle, en C89 il est interdit d'écrire quelque chose comme for(int i =0; i <9; i++). Vous pourrez le faire à partir du C99.

Exemple

Je pense qu'un exemple vous aidera à bien saisir tout ça :

1
2
3
4
5
6

int variable;

for (variable = 0 ; variable < 10 ; variable++)
{
     printf("variable vaut %d\n", variable);
}

Ce code affichera en sortie :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

variable vaut 0
variable vaut 1
variable vaut 2
variable vaut 3
variable vaut 4
variable vaut 5
variable vaut 6
variable vaut 7
variable vaut 8
variable vaut 9

Amusez-vous à changer l'initialisation, la condition ou la modification du compteur pour voir les changements. Il est essentiel de bien comprendre cette boucle qui est moins évidente que les deux autres, cependant très utilisée ; nous verrons mieux son intérêt et son utilité quand nous avancerons dans le cours.

Exercice

Nous allons nous entraîner avec quelque chose de vraiment basique. Nous allons calculer la somme de tous les nombres compris entre 1 et N. En clair, si je vous donne un nombre N, vous allez devoir calculer $1 + 2 + 3 + ... + (N-2) + (N-1) + N$ . Bien sûr, vous allez devoir utiliser une boucle.

A vos claviers ! Si c'est fini, voici la correction :

[secret]{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

#include <stdio.h>

int main (void)
{
    const unsigned int n = 250;
    unsigned int somme = 0;
    unsigned int i;

    for (i = 1; i <= n; ++i)
    {
        somme += i;
    }

    printf ("%d\n", somme);
    return 0;
}

}

Comme quoi, rien de bien compliqué. Une petit remarque cependant : ceux qui s’intéressent un peu aux mathématiques ont surement résolu cet exercice sans boucle. En effet, il faut savoir que cette somme peut se calculer plus facilement : elle vaut exactement $\frac {N \times (N+1)} {2}$.

Utilisation avancée

Les boucles for ne vous ont pas encore livré tous leurs secrets. Comme quoi, on en apprend toujours plus sur le C, même quand on pense déjà bien le connaître.

Plusieurs compteurs

Le nombre de compteurs ou de conditions n'est pas limité, comme le prouve le code suivant.

1

for (i = 0, j = 2 ; i < 10 && j < 12; i++, j += 2)

Ici, on définit deux compteurs i et j initialisés respectivement à 0 et 2. On exécute le contenu de la boucle tant que i est inférieur à 10 et que j est inférieur à 12, et on augmente i de 1 et j de 2 à chaque tour de boucle. Le code est encore assez lisible, cependant la modération est de mise, un trop grand nombre de paramètres rendant le for illisible.

Boucles imbriquées

Petite précision : il est possible d'imbriquer les boucles. Cela consiste simplement à mettre une ou plusieurs boucles dans une autre, comme ceci :

1
2
3
4
5
6
7

for (i = 0 ; i < 1000 ; ++i)
{
    for (j = i ; j < 1000 ; ++j)
    {
          /*  code  */
    }
}

Et cela marche aussi avec des boucles while, do while, etc. On peut ainsi imbriquer une boucle while dans une boucle do while, imbriquer une boucle while dans une boucle for, et j'en passe.

Cela peut servir. Pour donner un exemple, imaginons que je veuille savoir quelles sont les additions de deux nombres entiers dont la somme vaut 1000. En clair, je veux savoir quelles sont les valeurs possibles de a et de b tels que a + b = 1000. Pour cela, je peux résoudre le problème de manière naïve et utiliser deux boucles imbriquées pour tester toutes les possibilités.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

for (i = 0 ; i <= 1000 ; ++i)
{
    for (j = i ; j <= 1000 ; ++j)
    {
        if (i+j == 1000)
        {
            printf ("%d + %d = 1000 \n", i, j);
        }
    }
}

Branchements inconditionnels

Dans ce chapitre, ainsi que dans le chapitre précédent, on a vu comment modifier l’exécution de notre programme en fonction de certaines conditions. Ainsi, notre programme peut faire autre chose que mécaniquement passer à l'instruction suivante et peut recommencer son exécution à un autre endroit, suivant qu'une condition soit réalisée ou pas. Pour ce faire, on utilise des structures de contrôles. Au niveau de notre processeur, ces structures de contrôle sont fabriquées avec ce qu'on appelle des instructions de branchement conditionnelles : ce sont des instructions qui vont faire reprendre notre programme à un autre endroit si une condition est vraie, et qui ne font rien sinon. Ceux qui veulent savoir comment on utilise ces branchements pour fabriquer ces structures de contrôles peuvent aller lire le début de ce tutoriel (les trois premières sous-parties) : Structures de contrôle en assembleur.

Mais la plupart du temps, notre ordinateur supporte aussi des instructions de branchement inconditionnels. Ces instructions vont faire reprendre l’exécution de notre programme à un endroit bien précis, quelle que soit la situation. Celles-ci n'agissent pas suivant ce que leur dit une condition, mais vont toujours faire reprendre notre programme à un endroit bien précis. Alors certes, il s'agit d'instructions de notre processeur, qui sont souvent inaccessibles en C. Mais le langage C fournit quelques fonctionnalités qui fonctionnent exactement comme des branchements inconditionnels. Ils sont souvent utilisés de concert avec nos structures de contrôle, pour les améliorer, ou pour qu'elles fassent ce qu'il faut.

Il existe ainsi trois grands branchements inconditionnels en C :

  • celui qui permet de passer au tour de boucle suivant, sans finir celui en cours : continue ;
  • celui qui permet (entre autres) de quitter la boucle en cours : break ;
  • celui qui permet de sauter carrément dans un autre morceau de code goto.

Voyons un peu plus en détail ces branchements inconditionnels.

break

Nous avons déjà étudié le rôle de break au sein de l'instruction switch : il permettait simplement de quitter notre switch pour reprendre immédiatement après. Eh bien, sachez qu'on peut aussi l'utiliser avec des boucles. Son but ? Permettre de quitter une boucle (ou un switch, comme on l'a déjà vu), pour reprendre immédiatement après.

Boucle ou structure de contrôle simple

Pour illustrer ça, prenons un algorithme tout simple :

1
2
3
4
5
6
7

Pour (i = 0 ; i < 10 ; i++)
{
     Si i == 5
        Quitter la boucle;

     Sinon afficher i;
}

Notre but est de quitter si jamais la variable i atteint la valeur 5. Alors pour vous entrainer, essayez de coder vous-même la boucle sans regarder la solution.

[secret]{

1
2
3
4
5
6
7
8
9

int i;

for (i = 0 ; i < 10 ; i++)
{
    if (i == 5)
        break;

    printf("i = %d\n", i);
}

Et voici le résultat à l'exécution :

1
2
3
4
5

i = 0
i = 1
i = 2
i = 3
i = 4

L'exécution de la boucle a bien été arrêtée par break. }

Structures de contrôle imbriquées

Il est important de préciser que break ne permet de sortir que d'une seule boucle (ou d'une seule structure de contrôle de type if, else if, etc.). Ainsi, dans le cas de boucles imbriquées, on reviendra à la précédente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

#include <stdio.h>

int main(void)
{
    int i;

    for (i = 0; i < 10; i++)
    {
        printf("[for] i = %d\n", i);

        while (i < 5)
        {
            if (i == 4)
                break;

            printf("[while] i = %d\n", i);
            i++;
        }
    }

    return 0;
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

[for] i = 0
[while] i = 0
[while] i = 1
[while] i = 2
[while] i = 3
[for] i = 5
[for] i = 6
[for] i = 7
[for] i = 8
[for] i = 9

Dans ce code, il y a une boucle for qui contient une boucle while. Au départ, i vaut 0, et ainsi la condition est vraie et on rentre dans la boucle. On affiche la variable, et dès que i vaut 4, on quitte la boucle. Comme la condition du while est fausse, on ne rentre plus dedans, mais la boucle for continue de s'exécuter, et affiche les cinq valeurs suivantes de i.

continue

Le deuxième mot-clef est continue. Son rôle est d'arrêter l'itération en cours et de passer à la suivante. En gros, on peut dire que continue permet de terminer le tour de boucle en cours, et fait reprendre immédiatement au tour de boucle suivant. Prenons le même code algorithme que précédemment :

1
2
3
4
5
6
7

Pour (i = 0 ; i < 10 ; i++)
{
     Si i == 5
        continue;

     Sinon afficher i;
}

Qui est capable me dire ce que le code une fois traduit en C affichera ?

[secret]{

1
2
3
4
5
6
7
8
9

i = 0
i = 1
i = 2
i = 3
i = 4
i = 6
i = 7
i = 8
i = 9

}

On remarque que quand i a été mis à 5, la condition if (i == 5) est devenue vraie et continue a zappé l'itération en cours.

goto

Le troisième et dernier mot-clef que je souhaite vous présenter est goto. Ce mot-clef sert à sauter vers une autre partie du code. Il permet de reprendre l’exécution du programme à l'endroit qu'on veut.

Pour préciser l'endroit où l'on veut reprendre notre programme, le langage C fournit ce qu'on appelle des labels. Ces labels permettent de nommer une ligne de code, une instruction particulière, en lui donnant un nom. Pour identifier la ligne de code à laquelle on souhaite faire reprendre notre programme, il suffit ainsi d'ajouter un label à notre ligne de code, en rajoutant le nom du label suivi d'un caractère : devant notre instruction. Pour reprendre l’exécution du programme à une ligne de code bien précise, il suffit d'utiliser le mot-clé goto, suivi du nom du label de la ligne de code correspondante.

Exemple

Voici un algorithme tout simple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

Pour (i = 0 ; i < 10 ; i++)
{
     Si i == 5
        Sautez à l'étiquette Erreur;

     Sinon afficher i;
}

Erreur:
     Afficher que i vaut 5

Dans notre exemple, le label n'est autre que erreur. Ce label est défini plus bas dans le code : il est attaché à notre instruction qui affichera à l'écran que la variable vaut 5. Essayez de transposer cet algorithme en C.

[secret]{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

int i;

for (i = 0 ; i < 10 ; i++)
{
     if (i == 5)
         goto Erreur;

     printf("i = %d\n", i);
}

Erreur:
     puts("\ni vaut 5");

Et voici le résultat à l'exécution :

1
2
3
4
5
6
7

i = 0
i = 1
i = 2
i = 3
i = 4

i vaut 5

}

On voit bien que quand i atteint la valeur 5, le programme saute au label Erreur et affiche bien i vaut 5. Le label est ici après goto, mais peut très bien se trouver avant.

Goto Statement Considered Harmful

Il me faut cependant vous prévenir : de nos jours, il est tout de même assez rare qu'on utilise des goto, et certains langages de programmation ne permettent même pas de l'utiliser !

Pourquoi ? Eh bien en fait, il faut savoir qu'autrefois, goto était assez relativement utilisé par les programmeurs. Quand je dis autrefois, c'était avant les années 1960-1970. Nul doute que vous n'étiez pas encore nés à cette époque. À cette époque, on n'utilisait pas vraiment de structures de contrôles, et les programmeurs créaient des programmes en utilisant pas mal de goto pour compenser. Mais cet usage du goto a fini par être de plus en plus critiqué au fil du temps.

Première attaque contre le goto : en 1966, deux mathématiciens, Böhm et Jacopini, prouvèrent que l'on pouvait se passer totalement de goto en utilisant des structures de contrôle. La déchéance de goto était en marche.

Dans notre exemple vu plus haut, goto est effectivement superflu et peux facilement être remplacé par autre chose.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

int i;

/* code plus court et plus lisible */
for (i = 0 ; i < 10 ; i++)
{
     if (i == 5)
     {
         puts("i vaut 5");
         break;
     }

     printf("i = %d\n", i);
}

Mais certains programmeurs continuèrent d'utiliser du goto, en disant que c'était plus facile de programmer ainsi. Alors vint le coup de grâce final ! En mars 1968, un informaticien du nom d'Edsger Dijkstra écrivit un article qui fit sensation. Cet article portait le nom suivant : Goto Statement Considered Harmful. Dans cet article, Dijkstra porta de nombreuses critiques sur le goto, et préconisait de remplacer celui-ci par une utilisation judicieuse des structures de contrôles qu'on vient de voir dans ce chapitre et dans le chapitre précédent. Cet article lança un véritable débat dans les milieux académiques ainsi que parmi les programmeurs. Au final : goto perdit la bataille, l'usage des structures de contrôle usuelles se répandit dans le monde comme une trainée de poudre.

Le principal reproche qui est fait à ce pauvre goto est qu'il a tendance à rendre le code illisible. En comparaison, un code dans lequel on trouve des structures de contrôle est beaucoup plus compréhensible par un humain. C'est pour ce genre de raisons que l'usage de goto en C est très fortement déconseillé, en particulier pour les débutants.

Je vous parle de goto par souci d'exhaustivité, mais sachez qu'il est aujourd'hui rarement utilisé. L'un des très rares cas dans lesquels ce mot-clef prend son intérêt, c'est la gestion des erreurs. Cependant, nous ne verrons pas ce cas dans ce tutoriel. En attendant de découvrir comment bien utiliser goto, je vous recommande de ne pas l'utiliser.

Exercices

On ne le répète jamais assez, pratiquez ! Voici donc quelques exercices permettant de s'entraîner et de s'améliorer.

Calcul du PGCD de deux nombres

Commençons par une curiosité mathématique très prisée en programmation. Nous allons programmer un des premiers algorithmes qui aie été inventé à l'époque de l'Antiquité. On pourrait croire qu'il est tout de même assez récent et date des débuts de l'informatique. Mais ce n'est pas le cas : il a été inventé par Euclide, mathématicien de la Grèce antique, qui est considéré comme le créateur de la géométrie.

Énoncé

Mais cet algorithme n'est pas vraiment un algorithme géométrique. Cet algorithme effectue un traitement simple : il calcule le PGCD de deux nombres. Pour rappel, le PGCD de deux nombres a et b, n'est rien d'autre que le plus grand nombre qui peut diviser à la fois a et b.

Cet algorithme est très simple. On suppose que a est supérieur à b. On commence par affecter la valeur de b à a, ensuite, puis on attribue la valeur de r à b (le reste). Ensuite on calcule r de la division euclidienne (d'entiers) de a par b ; et on recommence toutes ces étapes jusqu'à ce que le reste soit nul. On a alors trouvé le résultat : c'est le b qui a été obtenu à la fin de ce processus.

Avec cette explication, vous avez tout ce qu'il vous faut : à vos claviers !

Correction

Cette correction est disponible ici.

Une overdose de lapins

C'est mignon un lapin, vous ne trouvez pas ? Si vous avez répondu oui, alors vous allez être ravi par cet exercice.

Énoncé

Au 13ème siècle, un mathématicien italien du nom de Leonardo Fibonacci posa un petit problème dans un de ses livres, qui mettait en scène des lapins. Ce petit problème mis en avant une suite de nombre particulière, nommée la suite de Fibonnaci, nommée du nom de son inventeur. Il fit les hypothèses suivantes.

  • Le premier mois, on place un couple de deux lapins dans l'enclos.
  • Les lapins se reproduisent tous les deux mois.
  • Dès qu'il le peut, tout couple de lapins capable d'avoir des enfants donne naissance à deux lapereaux, et donc à un nouveau couple.
  • Et enfin, pour éviter tout problème avec la SPA, les lapins ne meurent jamais.

Le problème est le suivant : combien il y a-t-il de couples (de paires) de lapins dans l'enclos au n-ième mois ? Le but de cet exercice est de coder un petit programme qui fasse le calcul automatiquement, et qui affiche le résultat sur la console.

Indice

[secret]{ Le fait est que ce problème est assez simple à résoudre en remarquant un petit truc. Si on dispose de x couples de lapins au mois N, celui donnera naissance à un nouveau couple deux mois plus tard. Deux mois plus tard, on se retrouvera donc avec x nouveaux couples de lapins, venant de la reproduction des couples d'il y a deux mois, auquel il faut ajouter les lapins qui étaient déjà là le mois d'avant. Autrement dit, si je note le nombre de lapins au n-ième mois $Fn$, on a : $F_n+2 = F_n+1 + F_n$. Ce qui peut aussi s'écrire $F_n = F_n-1 + F_n-2$.

Ensuite, au mois numéro 1, on a un seul couple de lapins. $F_1 = 1$. En enfin, au mois numéro 0, on a 0 lapins : l'expérience n'a pas encore commencée : $F_0 = 0$. En sachant ça, vous pouvez aisément coder le programme demandé. }

Correction

Et voilà, notre petit problème avec les lapins est résolu. Sympathique, non ?

Des pieds et des mains pour convertir mille miles

Si vous avez déjà voyagé en Grande-Bretagne ou aux États-unis, vous savez que les unités de mesure utilisées dans ces pays sont différentes des nôtres. Au lieu de notre cher système métrique, dont les stars sont les centimètres, mètres et kilomètres, nos amis outre-manche et outre-atlantique utilisent le système impérial, avec ses pouces, pieds et miles, voire lieues et furlongs ! Et pour empirer les choses, la conversion n'est pas toujours simple à effectuer de tête ... Aussi, la lecture d'un ouvrage tel que Le Seigneur des Anneaux, dans lequel toutes les distances sont exprimées en unités impériales, peut se révéler pénible.

Votre mission

Grâce au langage C, nous allons aujourd'hui résoudre tous ces problèmes ! Votre mission, si vous l'acceptez, sera en effet d'écrire un programme affichant un tableau de conversion entre miles et kilomètres. Le programme ne demande rien à l'utilisateur, mais doit afficher quelque chose comme ceci :

1
2
3
4
5
6
7

Km    Miles
5     8
10    16
15    24
20    32
25    40
30    48

Autrement dit, le programme compte les kilomètres de 5 en 5 jusqu'à 30, et affiche à chaque fois la valeur correspondante en miles. Un mile vaut exactement 1.609 344 km, cependant, nous allons utiliser une valeur approchée : nous prendrons huit-cinquièmes de kilomètre (soit 1.6km). Autrement dit, $1$ km $= \frac{8}{5}$ miles.

Bon courage et bonne chance !

Un indice ?

[secret]{ Ne lisez cette section que si vous avez déjà cherché par vous-mêmes, mais que malgré vos efforts, vous êtes dans une impasse.

Que doit faire notre programme ? Tout d'abord, nous devons afficher la première ligne, qui dit « Km - Miles ». Ceci ne comporte pas de difficulté. Ensuite, nous devons compter jusqu'à 30 de 5 en 5, et afficher la conversion en miles à chaque fois. Pour compter, il nous faudra utiliser une variable, qui prendra les valeurs successives du kilométrage. Nous compterons tant que la variable n'aura pas atteint 30 ; à chaque étape, nous afficherons la conversion et ajouterons 5. }

Correction !

Si vous avez lu l'indice, vous devriez normalement y arriver en reprenant les chapitres appropriés du cours. Voici un exemple de code possible.

Pour aller plus loin, vous pouvez vous amuser à faire la conversion inverse (miles vers km) ou à faire un affichage plus joli. Si vous vous sentez d'attaque, vous pouvez même demander les bornes et le pas à l'utilisateur. Pour parfaire le tout, vous pouvez même intégrer plusieurs unités différentes et demander à l'utilisateur laquelle il souhaite convertir !

Puissances de trois

Passons à un exercice un peu plus difficile, du domaine des mathématiques. Essayez de le faire même si vous n'aimez pas les mathématiques.

Consignes

Vous devez vérifier si un nombre est une puissance de trois, et afficher le résultat. De plus, si c'est le cas, vous devez afficher l'exposant qui va avec. Le nombre en question est stocké dans une variable de type int. Celle-ci a été déclarée avant dans votre code, et peut venir d'une entrée, ou être stockée quelque part, bref.

À vos marques... Prêts ? Codez !

Un indice ?

Comment savoir si un nombre est une puissance de trois ? Si vous êtes perdu dans cet exercice pas si facile, lisez les lignes suivantes.

[secret]{ Pour savoir si un nombre est une puissance de trois, vous pouvez utiliser le modulo. Attention cependant : si le reste vaut 0, le nombre n'est pas forcément une puissance de trois. Un dernier indice ? Si le nombre est bien une puissance, le reste est forcément non-nul. }

Correction !

Ça y est ? Terminé ? Vous pensez avoir tout bon ? Alors voici la correction. Comme quoi, ce n'était pas bien compliqué ! }

La disparition : le retour

Connaissez-vous le roman La Disparition ? Il s'agit d'un roman français de Georges Perec, publié en 1969. Sa particularité est qu'il ne contient pas une seule fois la lettre « e ». On appelle ce genre de textes privés d'une lettre des lipogrammes. Celui-ci est une prouesse littéraire, car la lettre « e » est la plus fréquente de la langue française : elle représente une lettre sur six en moyenne ! Le roman faisant environ 300 pages, il a sûrement fallu déployer des trésors d'inventivité pour éviter tous les mots contenant un « e ».

Si vous essayez de composer un tel texte, vous allez vite vous rendre compte que vous glissez souvent des « e » dans vos phrases sans même vous en apercevoir. Nous avons besoin d'un vérificateur qui nous sermonnera chaque fois que nous écrirons un « e ». C'est là que le langage C entre en scène !

Votre mission

Écrivez un programme qui demande à l'utilisateur de taper une phrase, puis qui affiche le nombre de « e » qu'il y a dans celle-ci. Une phrase se termine toujours par un point « . », un point d'exclamation « ! » ou un point d'interrogation « ? ». Pour effectuer cet exercice, il sera indispensable de lire la phrase caractère par caractère.

Exemple :

1
2
3
4

Entrez une phrase :
>>> Bonjour, comment allez-vous ?

Attention, 2 'E' ont été repérés dans votre phase !

Pour améliorer votre programme, vous pourriez demander à l'utilisateur de quelle lettre il souhaite se passer, plutôt que de toujours compter les « e ».

Notez qu'il existe un logiciel, infiniment plus évolué, qui fait ce genre de travail : grep. Cet utilitaire permet en fait de retrouver certaines expressions (lettres, mots ou autres) dans un fichier et de les afficher. Il peut également compter le nombre de fois qu'une expression apparaît dans un fichier. Quoi qu'il en soit, à vous de jouer !

Un indice ?

Avez-vous vraiment bien cherché ? Pourquoi ne pas aller faire un tour, puis revenir pour tenter de coder avec les idées fraîches ? Non, vraiment, vous voulez de l'aide ?

[secret]{ La première chose à faire est d'afficher un message de bienvenue, afin que l'utilisateur sache quel est votre programme. Ensuite, Il vous faudra lire les caractères tapés, un par un, jusqu'à ce qu'un point (normal, d'exclamation ou d'interrogation) soit rencontré. Dans l'intervalle, il faudra compter chaque « e » qui apparaîtra. Enfin, il faudra afficher le nombre de « e » qui ont été comptés, si présents. }

Correction

Là encore, il ne s'agit que d'une solution parmi tant d'autres. Aviez-vous pensé à gérer les « E » majuscules ? Si vous n'êtes pas arrivé à coder cet exercice, encore une fois, essayez de le refaire en cachant la solution. Persévérez jusqu'à ce que vous y arriviez tout seul.


Les fonctions

Nous avons découvert beaucoup de nouveautés dans les chapitres précédents. Nos programmes commencent à grossir, même s'ils restent encore modestes. C'est pourquoi il est important d'apprendre à découper son programme en fonctions.

  • A quoi ca sert ?
  • Déclarer une fonction
  • Utiliser une fonction
  • Les prototypes
  • Exercices

A quoi ca sert ?

Les fonctions ne sortent pas de nulle part : si on les a inventées, c'est qu'elles peuvent servir à quelque chose. Reste à comprendre pourquoi. Car les fonctions sont des inventions qui répondent à des besoins bien précis : grandement faciliter la vie du programmeur !

Lorsque vous créez un programme, le résultat sera une grosse suite d'instructions placées les unes à la suite des autres. Et parmi cette gigantesque suite d'instructions, il y a souvent des "sous-suites", des paquets d'instructions, des morceaux de code qui reviennent régulièrement et qui sont présents en plusieurs exemplaires dans le programme final. Ces sous-suites servent pratiquement toujours à exécuter une tâche bien précise et ont presque toujours une signification importante pour le programmeur. Par exemple, il va exister une de ces sous-suites qui va servir à calculer un résultat bien précis, communiquer avec un périphérique, ou autre chose encore.

Sans fonctions

Sans utiliser de fonctions, ces suites d'instructions sont présentes en plusieurs exemplaires dans le programme. Le programmeur doit donc recopier à chaque fois ces suites d'instructions, ce qui ne lui facilite pas la tâche. Et dans certains programmes, devoir recopier plusieurs fois la séquence d'instruction qui permet d'agir sur un périphérique ou de faire une action spéciale est franchement barbant !

De plus, ces suites d'instructions sont présentes plusieurs fois dans le programme final, exécuté par l'ordinateur. Et elles prennent de la place inutilement ! Mine de rien, à chaque fois qu'on recopie une de ces suites d'instructions récurrentes, on réinvente la roue. On perd ainsi beaucoup de temps à faire du copier-coller où à réécrire ce qui a déjà été fait. Les informaticiens ont donc inventé un moyen qui permet à ces suites d'instructions d'être présentes une seule fois dans le programme et d'être réutilisables au besoin. On a donc inventé les fonctions.

Avec les fonctions

La technique du sous-programme consiste à n'écrire qu'un seul exemplaire de ces suites d'instructions, et lui donner un nom. Au lieu de recopier cet exemplaire à chaque fois qu'on veut le réutiliser, il suffira tout simplement de placer son nom à la place. On appellera cette suite d'instruction une fonction.

Cet exemplaire sera écrit en dehors du programme principal, le fameux main que l'on utilise depuis le début. On peut remarquer que la fonction main est déjà une fonction, qui regroupe tout le programme.

Les fonctions, qui sont des bouts de code réutilisables au besoin, présentent ainsi de gros avantages.

  • Elles sont réutilisables. Plus besoin de recopier bêtement du code plusieurs fois de suite ! Avec une fonction, il suffit d'écrire une seule fois la fonction et ensuite de l'appeler autant de fois que l'on veut.
  • Elles sont plus facilement maintenables : si votre fonction est inadaptée, il suffit d'en changer le code. C'est beaucoup plus rapide que de modifier plusieurs exemplaires d'une même suite d'instruction, surtout s'ils sont disséminés n'importe comment dans tout le programme.
  • Elles permettent de mieux s'organiser : en divisant le code en fonctions, on peut ainsi séparer les différentes opérations et mieux s'y retrouver lors de la relecture du code. C'est quand même plus agréable de lire un code bien aéré qu'une gigantesque suite d'instructions de 2000 lignes dans laquelle tout est mélangé n'importe comment.

Bref, les fonctions n'ont que des avantages. Reste à savoir comment faire pour créer notre fonction, ce que nous allons voir dès la sous-partie suivante.

Déclarer une fonction

Une fonction n'est donc qu'un vulgaire bloc de code, un morceau de programme. Mais ce morceau de programme a tout de même quelques caractéristiques.

Par exemple, imaginons que je veuille créer une fonction qui calcule une opération mathématique complexe. On va prendre le logarithme d'un nombre, par exemple (si vous ne savez pas ce que c'est, ce n'est pas important). Notre fonction va donc devoir manipuler un nombre, celui dont on veut calculer le logarithme. De même, elle va fournir un résultat, le logarithme du nombre. Avec cet exemple, on voit qu'une fonction doit être définie par trois éléments.

Elle a parfois besoin de paramètres : ce sont toutes les informations que l'on donne à la fonction pour qu'elle puisse travailler. Ces informations vont donner des données que notre fonction va devoir manipuler afin d'obtenir un résultat. Dans notre exemple, le nombre dont on veut calculer le logarithme est un paramètre de la fonction.

Elle contient aussi du code : ce code va dire ce que va faire la fonction. C'est tout ce qui compose l'intérieur d'une fonction, une fonction sans code, une fonction vide entre autres est une fonction inutile.

Et enfin, notre fonction peut renvoyer un résultat. Ce n'est pas obligatoire d'en renvoyer un, mais la majorité des fonctions renvoient un résultat.

Déclarer une fonction

Maintenant que vous avez vu la partie "théorique", regardons comment tout cela s'applique en C. On vient de voir qu'une fonction est constituée de plusieurs éléments. Tous ces éléments seront donc indiqués dans notre fonction, à des endroits différents. Mais commençons par le commencement : nous allons d'abord apprendre à déclarer une fonction : cela consiste à indiquer à notre langage qu'on veut créer une fonction. Une fois celle-ci déclarée, il ne restera plus qu'à dire ce qu'elle fait, en écrivant son code et en spécifiant le résultat.

Pour déclarer une fonction, nous allons devoir donner quelques informations sur notre fonction, et notamment sur ses arguments et sur son résultat. Ces fameuses informations sont :

  • le type de retour : il s'agit du type du résultat de la fonction. Après tout, notre fonction pourrait aussi bien renvoyer un nombre entier, qu'un flottant ou un caractère, aussi préciser le type du résultat est obligatoire.
  • le nom de la fonction : c'est vous qui le choisissez. Les règles sont les mêmes que pour les variables.
  • les paramètres : les paramètres sont ce avec quoi la fonction va travailler. Vous pouvez en mettre autant que vous voulez.

Pour déclarer une fonction, il va falloir préciser ces trois détails. Voici comment procéder pour préciser ces trois paramètres et ainsi déclarer une fonction :

1
2
3
4

type identificateur (paramètres)
{
     /* corps de la fonction */
}

À l'intérieur de notre fonction (dans ce qui est nommé le corps de la fonction dans l'exemple du dessus), on va y placer le code de notre fonction. Pour illustrer ce concept, prenons un exemple tout banal :

1
2
3
4

int ma_fonction(int parametre)
{
    /* Instructions */
}

J'ai ici défini une fonction appelée ma_fonction. Elle prend un int comme paramètre, et a pour résultat un int.

void

Il se peut que l'on est besoin de coder une fonction qui ne retourne aucun résultat. C'est un cas courant en C. Ce genre de fonction est appelé procédure. Pour écrire une procédure, il faut indiquer à la fonction en question qu'elle ne doit rien retourner. Pour ce faire, il existe un "type de retour" spécial : void. Ce type signifie "vide", et sert à indiquer que la fonction n'a pas de résultat.

Ce mot-clef sert aussi à indiquer qu'une fonction ne prend aucun paramètre. C'est assez rare, mais cela arrive. Dans ce cas, il suffit de définir la fonction en mettant void dans la liste des paramètres.

Paramètres

Un paramètre sert à fournir des informations à la fonction lors de son exécution. La fonction printf par exemple récupère ce qu'elle doit afficher dans la console à l'aide de paramètres. Vous pouvez envoyer autant de paramètres à une fonction que vous voulez, il suffit de les séparer à l'aide d'une virgule. Cependant, ils doivent avoir des noms différents, tout comme les variables. Il est aussi possible de ne pas mettre d'arguments dans notre fonction, comme indiqué plus haut.

Exemples

Pour vous faire bien saisir toutes ces notions, entrainez vous à déclarer des fonctions. Essayez de déclarer une fonction :

  • retournant un double et prenant un char et un int en argument ;
  • retournant un unsigned short et ne prenant aucun paramètre ;
  • retournant un float et prenant un int, un long et un double en paramètres ;
  • retournant un int et prenant un int constant et un unsigned long en paramètre ;
  • ne retournant rien et ne prenant aucun paramètre ;

Je pense qu'avec tous ces exemples vous commencez à bien saisir comment déclarer une fonction.

Le corps d'une fonction

Intéressons-nous maintenant au corps de la fonction, le code qu'il y a à l'intérieur. Comme pour la fonction main, le code est à l'intérieur des accolades. Et ce code, c'est nous qui allons l'écrire. Alors que doit-on écrire ? En bref, ce que vous voulez que la fonction fasse.

return

À ce stade, vous savez comment déclarer une fonction sans problème. Il vous manque juste une dernière information : comment faire pour préciser quel est le résultat de la fonction ? Comment lui dire : « Le résultat que tu dois renvoyer, c'est ça » ? Pour cela, on doit utiliser le mot-clef return. Une fois que vous avez une variable qui contient le résultat que vous voulez, il suffit d'écrire return, suivi du nom de la variable, le tout suivi d'un point-virgule. À ce moment-là, la fonction s’arrêtera et renverra son résultat immédiatement. Cela signifie que tout le code qui est écrit après le return ne sera pas exécuté : notre fonction a déjà son résultat de disponible, pourquoi faire quoi que ce soit de plus ?

Petite remarque : un return peut parfaitement renvoyer une valeur qui est une constante. Pour donner un exemple, on va prendre une fonction assez simple, qu'on nommera valueSign. Notre fonction va prendre un argument de type int en entrée et va renvoyer :

  • 0 si cet argument est nul ;
  • 1 si celui-ci est positif ;
  • et -1 si celui-ci est négatif.

Une version naïve de cette fonction s'écrirait comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

int valueSign (int a)
{
    if ( a > 0 )
    {
        return 1 ;
    }

    else if ( a < 0 )
    {
        return -1 ;
    }

    else
    {
        return 0 ;
    }
}

Variables locales

Autre détail, qui concerne les variables que vous déclarez à l'intérieur du corps d'une fonction. Autant vous prévenir tout de suite : n'essayez pas d’accéder à une variable qui est déclarée dans une fonction en dehors de celle-ci. Si vous faites cela, vous allez au-devant de graves ennuis.

En effet, sauf cas exceptionnels, il faut savoir que ces variables ne sont accessibles que dans notre fonction, et pas de l'extérieur. C'est ainsi : les variables déclarées à l'intérieur de la fonction sont des données temporaires qui lui permettent de faire ce qu'on lui demande. Ces données sont des données internes à notre fonction, qu'elle seule doit manipuler et qui ne doivent généralement pas être accessibles à d’autres programmes ou d'autres fonctions. Si ce n'est pas le cas, c'est que cette variable doit être passée en paramètre ou qu'elle doit être renvoyée en tant que résultat.

En fait, vous pouvez considérer que dans la majorité des cas, ces variables déclarées dans une fonction sont créées quand on commence l’exécution de la fonction, et qu'elles sont enlevées de la mémoire une fois que la fonction renvoie son résultat. Si je dis la majorité des cas, c'est qu'il y a une exception. Mais laissons cela de côté pour le moment : le temps de parler des variables statiques n'est pas encore arrivé.

Exemple

Prenons un exemple tout bête. Vous voulez faire une fonction qui renvoie le carré d'un nombre passé en paramètre. Commençons déjà par traduire notre fonction en pseudo-code :

1
2
3
4
5

Entrée : nombre

Carré :
       Multiplier nombre par lui-même
       Retourner nombre

Maintenant, exerçons-nous en codant cet algorithme. Je vous encourage à le faire avant de regarder la solution, cela vous fera progresser. Sinon, celle-ci ce trouve ici.

Maintenant que vous avez saisi le principe, nous allons apprendre à utiliser nos fonctions, car pour l'instant elles ne font rien.

Utiliser une fonction

Nous avons déjà utilisé quelques fonctions, notamment printf et scanf. Pour les utiliser, il suffit de taper le nom de la fonction suivi des paramètres entre parenthèses. Eh bien, pour nos fonctions c'est exactement la même chose. Prenons pour illustration la fonction carre vue dans la partie précédente. Voici le programme complet :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

#include <stdio.h>

int carre(int nombre)
{
    return nombre * nombre;
}

int main(void)
{
    int nombre, nombre_au_carre;

    puts("Entrez un nombre : ");
    scanf("%d", &nombre);

    nombre_au_carre = carre(nombre);

    printf("Voici le carre de %d : %d\n", nombre, nombre_au_carre);
    return 0;
}

On demande à l'utilisateur de rentrer un nombre entier. Une fois ceci fait, on appelle la fonction avec cette ligne :

1

nombre_au_carre = carre(nombre);

On dit que nombre est un argument de la fonction carre. Paramètres et arguments sont très liés ; la différence entre les deux est que les premiers apparaissent lors que la définition de la fonction alors que les seconds apparaissent lors de son l'appel. On demande ensuite à attribuer à la variable nombre_au_carre la valeur retournée par la fonction carre. Ainsi, si nombre vaut 4, on appelle la fonction, et celle-ci retournera alors 16 (car $4^2 = 16$).

Un petit code commenté ?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

#include <stdio.h>

/* d) le nombre passé en paramètre en c) est récupéré */

int carre(int nombre)
{
    /* e) on fait le calcul et on renvoie la valeur */
    return nombre * nombre ;
}

int main(void)
{
    /* a) on déclare nos variables */
    int nombre, nombre_au_carre;

    puts("Entrez un nombre : ");
    /* b) on récupère la valeur de nombre */
    scanf("%d", &nombre);

    /* c) on appelle la fonction carre */
    nombre_au_carre = carre(nombre);
    /* f) nombre_au_carre vaut maintenant la valeur retournée par la fonction carre */

    /* g) on affiche le résultat */
    printf("Voici le carre de %d : %d\n", nombre, nombre_au_carre);
    return 0;
}

Ça va, vous suivez ? C'est simple : lors de l'appel de la fonction, on lui donne des arguments et le programme s'occupe du reste. C'est lui qui fera les calculs et qui renverra la valeur à afficher.

Sachez qu'il est possible d'optimiser notre code en se passant de cette variable intermédiaire qui stocke le résultat. En effet, on peut très bien appeler la fonction directement dans le printf, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>

int carre(int nombre)
{
    return nombre * nombre;
}

int main(void)
{
    int nombre;

    puts("Entrez un nombre :");
    scanf("%d", &nombre);

    printf("Voici le carre de %d : %d\n", nombre, carre(nombre));
    return 0;
}

Ce code revient au même que le précédant, car le deuxième paramètre de printf sera la valeur retournée par la fonction. Autrement dit, c'est un nombre dans les deux cas, et affichera bien la même chose à l'écran :

1
2
3

Entrez un nombre :
10
Voici le carré de 10 : 100

La fonction main appelle la fonction printf, qui elle-même appelle la fonction carre. C'est une imbrication de fonctions. Ainsi, une fonction peut en appeler une autre ; c'est ainsi que tout programme écrit en C fonctionne. Entrainez-vous à appeler des fonctions en utilisant toutes les fonctions que nous avons vues au cours de ce chapitre. Nous verrons d'autres exercices en fin de chapitre.

Appel de fonctions

Il faut aussi préciser une chose importante sur les arguments : si on passe une variable en argument d'une fonction, la variable en elle-même ne sera pas modifiée. La fonction utilisera à la place une copie de la variable ! C'est très important, et c'est source de comportements bizarres si on ne fait pas attention. Retenez bien : les arguments d'une fonction sont copiés et c'est cette copie qui est manipulée par notre fonction. Considérons l'exemple suivant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>

void fonction(int nombre)
{
    ++nombre;
    printf("Variable nombre dans la fonction : %d\n", nombre);
}

int main(void)
{
    int nombre = 5;

    fonction(nombre);
    printf("Variable nombre dans le main : %d\n", nombre);

    return 0;
}

1
2

Variable nombre dans la fonction : 6
Variable nombre dans le main : 5

Vous avez vu ? La fonction manipule bien une copie de la variable, car lorsque l'on revient dans la fonction main, la valeur de la variable est toujours la même : l'original n'a pas été modifié. Nous verrons néanmoins dans quelques chapitres comment modifier l'original dans la fonction et non une copie.

On peut légitimement se demander pourquoi un tel comportement. La raison est assez complexe, mais je peux au moins vous dire que la raison est fortement liée au matériel de notre ordinateur. La façon dont les fonctions sont exécutées au niveau de notre ordinateur impose que ces arguments soient copiés. Pour le moment, ce n'est pas de votre niveau, mais vous aurez surement la réponse plus tard.

Les prototypes

Avez-vous remarqué qu'à chaque fois je mets ma fonction avant la fonction main ? En effet, mettre la fonction après le main provoquera un comportement indéterminé. La compilation pourrait très bien marcher comme elle pourrait planter. En effet, lorsque la fonction est placée avant, le compilateur connait ses paramètres et sa valeur de retour. Du coup, quand on appelle la fonction, le compilateur vérifie que les arguments qu'on lui donne sont bons. Si au contraire la fonction est après, le compilateur ne connait pas la fonction. Du coup, il lui fixe arbitrairement des caractéristiques : la fonction retourne un int et prend un nombre indéterminé de paramètres. Et quand on tente d'appeler la fonction, la compilation plante, car les arguments ne correspondent pas aux yeux du compilateur.

Heureusement, il existe une sorte de mode d'emploi qui permet d'indiquer toutes les caractéristiques d'une fonction au compilateur. Avec cette indication, on peut placer la fonction où on veut dans le code. Et ce mode d'emploi a un nom : un prototype. Un prototype se déclare quasiment comme une fonction :

1

type nom_de_la_fonction(arguments);

Voilà à quoi ressemble un prototype. Placez-le simplement tout en haut de votre fichier et c'est bon ! votre fonction est utilisable partout dans le code. Essayez donc d'appliquer ça à la fonction carre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

#include <stdio.h>

int carre(int nombre);

int main(void)
{
    int nombre, nombre_au_carre;

    puts("Entrez un nombre :");
    scanf("%d", &nombre);

    nombre_au_carre = carre(nombre);
    printf("Voici le carre de %d : %d\n", nombre, nombre_au_carre);

    return 0;
}

int carre(int nombre)
{
    nombre *= nombre;
    return nombre;
}

Ce code marche parfaitement, vous pouvez tester si vous voulez. La seule chose à retenir c'est le point-virgule après le prototype. Il est obligatoire, sinon la compilation plantera.

Si vous mettez toutes vos fonctions avant le main, les prototypes peuvent sembler inutiles, mais je vous encourage à les utiliser. Dès que vous aurez des projets conséquents, vous serez obligés de les déclarer.

Avant de conclure sur cette partie, je tiens à préciser quelque chose : dans les paramètres du prototype, seuls les types sont vraiment nécessaires, les identificateurs sont facultatifs. Ainsi le prototype précédant peut s'écrire :

1

int carre(int);

Cependant, cette astuce ne marche que pour les prototypes, n'allez pas le faire pour une fonction (je vous laisse chercher pourquoi).

Exercices

Comme le dit le vieil adage : "C'est en forgeant qu'on devient forgeron". C'est donc en vous entrainant que vous devenez petit à petit des programmeurs C. Dans cette partie, je vais vous proposer des algorithmes de fonctions, ce sera à vous de les traduire en C. Je mets la solution au cas où vous auriez vraiment du mal, mais je vous encourage à le faire avant de regarder la solution. C'est comme ça que vous progresserez le plus.

Afficher un rectangle

Le premier exercice que je vous propose est d'afficher un rectangle. C'est très simple vous allez voir. Voici l'algorithme que je vous propose :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

Entrée : longueur, largeur

Afficher
    Déclarer deux variables i (longueur) et j (largeur);

    Pour (i = 0 ; i < longueur ; i++)
    {
        Pour (j = 0; j < largeur ; j++)
        {
            Afficher le symbole '*'
        }

        Sauter une ligne
    }

Le code devra afficher ceci dans la console :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

Donnez la longueur :
5
Donnez la largeur :
3

***
***
***
***
***

Correction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

#include <stdio.h>

/* Prototype avec const qui permet de voir que l'on ne modifie pas la variable */
void rectangle(const int longueur, const int largeur);

int main(void)
{
    int longueur, largeur;

    puts("Donnez la longueur : ");
    scanf("%d", &longueur);

    puts("Donnez la largeur : ");
    scanf("%d", &largeur);

    puts(" ");

    rectangle(longueur, largeur);
    return 0;
}

void rectangle(const int longueur, const int largeur)
{
    int i, j;

    for (i = 0; i < longueur; i++)
    {
        for (j = 0; j < largeur; j++)
        {
            putchar('*');
        }

        puts(" ");
    }
}

Essayez aussi d'afficher le rectangle dans l'autre sens si vous voulez, cela vous servira d'entrainement.

Afficher un triangle

Cette fonction est similaire à la précédente, mais pas tout à fait identique, sauf que cette fois on veut afficher un triangle. Rassurez-vous, l'exercice est plus simple qu'il n'y parait. Pour bien faire cet exercice, on va utiliser d'abord le pseudo-code pour écrire notre algorithme. En voici un tout simple que je vous propose :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

Entrée : nombre_de_lignes

Afficher
    Déclarer deux variables i (nombre de lignes) et j (nombre de colonnes);

    Pour (i = 0 ; i < nombre_de_lignes ; i++)
    {
        Pour (j = 0; j <= i ; j++)
        {
            Afficher le symbole '*'
        }

        Sauter une ligne
    }

Ce code, une fois traduit, devrait afficher la sortie suivante dans la console :

1
2
3
4
5
6
7
8

Donnez un nombre :
5

*
**
***
****
*****

Bien entendu, la taille du triangle variera en fonction du nombre que l'on donne.

Correction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

#include <stdio.h>

void triangle(const int nombre);

int main(void)
{
    int nombre;

    puts("Donnez un nombre : ");
    scanf("%d", &nombre);

    puts(" ");
    triangle(nombre);

    return 0;
}

void triangle(const int nombre)
{
    int i, j;

    for (i = 0; i < nombre; i++)
    {
        for (j = 0; j <= i; j++)
        {
            putchar('*');
        }

        puts(" ");
    }
}

Coupure

Imaginez le scénario suivant. Vous êtes un agent dans une banque et aujourd'hui vous recevez votre client. Celui-ci vous demande de lui livrer une somme avec la coupure qu'il vous a indiquée. Par exemple, il vous dit qu'il souhaite récupérer 300 000 € uniquement en billets de 500€ et de 200€. Dans ce cas vous lui donnerez le plus de billets de 500€ possible puis vous continuerez avec des billets de 200€.

Ici, la coupure sera la suivante :

  • Des billets de 100€.
  • Des billets de 50€.
  • Des billets de 20€.
  • Des billets de 10€.
  • Des pièces de 2€.
  • Des pièces de 1€.

Votre client vous indique la somme qu'il souhaite et vous la lui fournissez en tenant compte de la coupure spécifique.

1
2
3
4
5
6
7

Quelle somme voulez-vous ? 285
2 billet(s) de 100.
1 billet(s) de 50.
1 billet(s) de 20.
1 billet(s) de 10.
2 pièce(s) de 2.
1 pièce(s) de 1.

Exemple de prototype pour la fonction :

1

void coupure (const int somme);

Et exemple de pseudo-code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

Entrée : somme

Afficher
    Déclarer deux variables n (nombre de billet) = somme et tmp (variable temporaire) = somme;

    Si n /= 100 > 0
    {
        Afficher "n billet(s) de 100."
    }

    tmp -= n * 100;
    n = tmp / 50;

    Si (tmp / 50) > 0
    {
        Afficher "n billet(s) de 50."
    }

    tmp -= n * 50;
    n = tmp / 20;

    Si (tmp / 20) > 0
    {
        Afficher "n billet(s) de 20."
    }

    tmp -= n * 20;
    n = tmp / 10;

    Si (tmp / 10) > 0
    {
        Afficher "n billet(s) de 10."
    }

    tmp -= n * 10;
    n = tmp / 2;

    Si (tmp / 2) > 0
    {
        Afficher "n piece(s) de 2."
    }

    Si ((tmp -= n * 2) > 0)
    {
        Afficher "tmp piece(s) de 1."
    }

Correction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

#include <stdio.h>

void coupure(const int somme)
{
    int n = somme, tmp = somme;

    if((n /= 100) > 0)
    {
        printf("%d billet(s) de 100.\n", n);
    }

    tmp -= n * 100;
    n = tmp / 50;
    if((tmp / 50) > 0)
    {
        printf("%d billet(s) de 50.\n", n);
    }

    tmp -= n * 50;
    n = tmp / 20;
    if((tmp / 20) > 0)
    {
        printf("%d billet(s) de 20.\n", n);
    }

    tmp -= n * 20;
    n = tmp / 10;
    if((tmp / 10) > 0)
    {
        printf("%d billet(s) de 10.\n", n);
    }

    tmp -= n * 10;
    n = tmp / 2;
    if((tmp / 2) > 0)
    {
        printf("%d billet(s) de 2.\n", n);
    }

    if((tmp -= n * 2) > 0)
    {
        printf("%d piece(s) de 1.\n", tmp);
    }
}

int main(void)
{
    int somme;

    scanf("%d", &somme);
    coupure(somme);

    return 0;
}


Découper son projet

Ce chapitre est la suite directe du précédent : nous allons voir comment découper nos projets en plusieurs fichiers. En effet, même si l'on découpe bien son projet en fonctions, ce dernier est difficile à relire si tout est contenu dans le même fichier. Ce chapitre a donc pour but de vous apprendre à découper vos projets efficacement.

  • Portée et masquage
  • Diviser pour mieux régner
  • Les fichiers d'en-têtes

Portée et masquage

La notion de portée

Avant de voir comment diviser nos programmes en plusieurs fichiers, il est nécessaire de vous présenter une notion importante, celle de portée. La portée d'une variable ou d'une fonction est la partie du programme où cette dernière est utilisable. Il existe plusieurs types de portées, cependant nous n'en verrons que deux :

  • au niveau d'un bloc ;
  • au niveau d'un fichier.

Au niveau d'un bloc

Une portée au niveau d'un bloc signifie qu'une variable n'est utilisable, visible que de sa déclaration jusqu'à la fin du bloc dans lequel elle est déclarée. Illustration :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <stdio.h>

int main(void)
{
    {
        int nombre = 3;
        printf("%d\n", nombre);
    }

/* Incorrect ! */
    printf("%d\n", nombre);
    return 0;
}

Dans ce code, la variable nombre est déclarée dans un sous-bloc. Sa portée est donc limitée à ce dernier et elle ne peut pas être utilisée en dehors.

Au niveau d'un fichier

Une portée au niveau d'un fichier signifie qu'une variable n'est utilisable, visible, que de sa déclaration jusqu'à la fin du fichier dans lequel elle est déclarée. Pour obtenir une variable ayant une portée au niveau d'un fichier, il est nécessaire de la déclarer en dehors de tout bloc, par exemple comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

#include <stdio.h>

int nombre = 3;

int triple(void)
{
    return nombre * 3;
}

int main(void)
{
    nombre = triple();
    printf("%d\n", nombre);
    return 0;
}

Dans ce code, la variable nombre a une portée au niveau du fichier et peut par conséquent être aussi bien utilisée dans la fonction triple que dans la fonction main.

La notion de masquage

En voyant les deux types de portées, vous vous êtes peut-être posé la question suivante : que se passe-t-il s'il existe plusieurs variables et/ou plusieurs fonctions de même nom ? Eh bien, cela dépend de la portée de ces dernières : si elles ont la même portée comme dans l'exemple ci-dessous, alors le compilateur sera incapable de déterminer à quelle variable ou à quelle fonction le nom fait référence et, dès lors, retournera une erreur.

1
2
3
4
5
6

int main(void)
{
    int nombre = 10;
    int nombre = 20;
    return 0;
}

En revanche, si elles ont des portées différentes, alors celle ayant la portée la plus faible sera privilégiée, on dit qu'elle masque celle(s) de portée plus élevée. Autrement dit, dans l'exemple qui suit, c'est la variable du bloc de la fonction main qui sera affichée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

#include <stdio.h>

int nombre = 10;

int main(void)
{
    int nombre = 20;

    printf("%d\n", nombre);
    return 0;
}

Notez que je dis « celle(s) de portée plus élevée » car les variables déclarées dans un sous-bloc ont une portée plus faible que celle déclarée dans le bloc supérieure. Ainsi le code ci-dessous est parfaitement valide et affichera 30.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

#include <stdio.h>

int nombre = 10;

int main(void)
{
    int nombre = 20;

    if (nombre == 20)
    {
        int nombre = 30;
        printf("%d\n", nombre);
    }

    return 0;
}

Diviser pour mieux régner

Création d'un nouveau fichier source

Bien, maintenant que nous avons vu la notion de portée, voyons comment diviser notre projet en plusieurs fichiers. Voici la marche à suivre pour crée un nouveau fichier .c, appelé fichier source.

Pour Code::Blocks

Pour ajouter des fichiers au projet, il faut faire File -> New -> File.... De là, vous allez tomber sur une fenêtre comme celle-ci :

Première fenêtre dans l'étape de création d'un fichier source

Pour ajouter un fichier source, cliquez sur C/C++ source. Si vous êtes sous Windows, vous pouvez passer la première fenêtre sans problème, celle-ci indiquant simplement que vous vous apprêtez à créer un fichier. Vous arrivez ensuite sur une fenêtre où l'on vous demandera plusieurs renseignements (cf. ci-dessous).

La première case demande où sera situé le fichier. Cliquez sur les trois-points pour ouvrir une fenêtre. Par défaut, celle-ci s'ouvre sur le dossier contenant votre projet. Sinon, déplacez-vous pour atteindre le dossier de votre projet. Donnez ensuite un nom au fichier (appelons-le autre.c). Laissez de côté le deuxième cadre, il sera rempli automatiquement. Enfin, cochez toutes les cases du bas (Debug et Release), puis cliquez sur Finish.

Pour Visual Studio

Pour Visual Studio 2010 la manipulation est simple. Faites un clic droit sur votre projet, puis Ajouter -> Nouvel élément (ou bien Ctrl + Maj + A). Vous tombez sur cette fenêtre :

Pour ajouter un fichier source, il suffit de cliquer sur Fichier C++ (.cpp), de donner un nom au fichier (pour l'exemple, autre.c) en précisant bien l'extension .c (sinon votre fichier sera considéré comme un fichier C++ et votre projet sera donc compilé par le compilateur C++) et de cliquer sur Ajouter.

Pour Xcode

Sous Xcode, afin d'ajouter un fichier à votre projet, appuyez sur command + N, vous devriez voir ceci :

Sélectionnez C File et appuyez sur Next. On vous demande alors le nom de votre fichier (nommez-le autre.c), dans quel dossier vous souhaitez l'enregistrer et dans quel projet l'ajouter. Une fois ces champs remplis, appuyez sur Create. Lorsque vous revenez dans votre projet, vous remarquerez, qu'en dessous de main.c, il y a votre nouveau fichier.

Compilation manuelle

Si jamais vous compilez en ligne de commande, il vous suffit de créer un nouveau fichier .c et de le rajouter lors de la compilation.

Pour la suite de ce chapitre, je vais considérer que vous avez donc deux fichiers source. Dans mon cas, il s'agira de main.c et de autre.c. Continuons donc notre découvertes des portées.

Les fonctions

Dans le chapitre précédent, nous avions, entre autres, créé une fonction triple que nous avons placée dans le même fichier que la fonction main. Essayons à présent de les répartir dans deux fichiers distincts, par exemple comme ceci :

1
2
3
4
5
6

/* Fichier autre.c */

int triple(int nombre)
{
    return nombre * 3;
}

1
2
3
4
5
6
7

/* Fichier main.c */

int main(void)
{
    int nombre = triple(3);
    return 0;
}

Si vous testez ce code, vous aurez droit à un bel avertissement de votre compilateur du type « implicit declaration of function 'triple' ». Quel est le problème ? Le problème est que la fonction triple n'est pas déclarée dans le fichier main.c et que le compilateur ne la connaît donc pas lorsqu'il compile le fichier. Pour corriger cette situation, nous devons déclarer la fonction en signalant au compilateur que cette dernière se situe dans un autre fichier. Pour ce faire, nous allons inclure le prototype de la fonction triple dans le fichier main.c en le précédant du mot-clé extern, qui signifie que la fonction est externe au fichier.

1
2
3
4
5
6

/* Fichier autre.c */

int triple(int nombre)
{
    return nombre * 3;
}

1
2
3
4
5
6
7
8
9

/* Fichier main.c */

extern int triple(int nombre);

int main(void)
{
    int nombre = triple(3);
    return 0;
}

En terme technique, on dit que la fonction triple est définie dans le fichier autre.c (car c'est là que se situe le corps de la fonction) et qu'elle est déclarée dans le fichier main.c. Sachez qu'une fonction ne peut être définie qu'une seule fois.

Pour information, notez que le mot-clé extern est facultatif devant un prototype (il est implicitement inséré par le compilateur). Je vous conseille cependant de l'utiliser, dans un soucis de clarté et de symétrie avec les déclarations de variables.

Les variables

La même méthode peut être appliquée aux variables, mais uniquement à celle ayant une portée au niveau d'un fichier. Également, à l'inverse des fonctions, il est plus difficile de distinguer une définition d'une déclaration de variable (elles n'ont pas de corps comme les fonctions). La règle pour les différencier est qu'une déclaration sera précédée du mot-clé extern alors que la définition non. C'est à vous de voir dans quel fichier vous souhaitez définir la variable, mais elle ne peut être définie qu'une seule fois. Enfin, sachez que seule la définition peut comporter une initialisation. Ainsi, cet exemple est tout à fait valide :

1
2
3
4

/* Fichier autre.c */

int nombre = 10;    /* une définition */
extern int autre;   /* une déclaration */

1
2
3

/* Fichier main.c */
extern int nombre;  /* une déclaration */
int autre = 10;     /* une définition */

Alors que celui-ci, non :

1
2
3
4

/* Fichier autre.c */

int nombre = 10;    /* il existe une autre définition */
extern int autre = 10;  /* une déclaration ne peut pas comprendre une initialisation */

1
2
3
4

/* Fichier main.c */

int nombre = 20;    /* il existe une autre définition */
int autre = 10;     /* une définition */

On m'aurait donc menti ?

Je vous ai dit plus haut qu'il n'était possible de définir une variable ou une fonction qu'une seule fois, en fait ce n'est pas tout à fait vrai. Il est possible de rendre une variable (ayant une portée au niveau d'un fichier) ou une fonction locale à un fichier en précédant sa définition du mot-clé static. De cette manière, la variable ou la fonction est interne au fichier où elle est définie et n'entre pas en conflit avec les autres variables ou fonctions locales à d'autres fichiers. La contrepartie est que la variable ou la fonction ne peut être utilisée que dans le fichier où elle est définie (c'est assez logique). Ainsi, l'exemple suivant est tout à fait correct et affichera 20.

1
2

/* Fichier autre.c */
static int nombre = 10;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

/* Fichier main.c */
#include <stdio.h>

static int nombre = 20;

int main(void)
{
    printf("%d\n", nombre);
    return 0;
}

Les fichiers d'en-têtes

Pour terminer ce chapitre, il ne nous reste plus qu'à voir les fichiers d'en-têtes. Jusqu'à présent, lorsque vous voulez utiliser une fonction ou une variable définie dans un autre fichier, vous insérez sa déclaration dans le fichier ciblé. Seulement voilà, si vous utilisez dix fichiers et que vous décidez un jour d'ajouter ou de supprimer une fonction ou une variable ou encore de modifier une déclaration, vous vous retrouvez Gros-Jean comme devant et vous êtes bon pour modifier les dix fichiers ... ce qui n'est pas très pratique.

Pour résoudre ce problème, on utilise des fichiers d'en-têtes (d'extension .h). Ces derniers contiennent conventionnellement des déclarations de fonctions et de variables et sont inclus via la directive #include, dans les fichiers qui utilisent les fonctions et variables en question.

La création d'un fichier d'en-tête fonctionne de la même manière que celle d'un fichier source. Les utilisateurs d'un environnement de développement intégré devront simplement penser à sélectionner Header File (Fichier d'en-tête en français) au lieu de Source File.

La structure d'un fichier d'en-tête est généralement de la forme :

1
2
3
4
5
6

#ifndef CONSTANTE_H
#define CONSTANTE_H

/* les déclarations */

#endif

Les directives du préprocesseur sont là pour éviter les inclusions multiples : vous devez les utiliser pour chacun de vos fichiers d'en-têtes. Vous pouvez remplacer CONSTANTE par ce que vous voulez, le plus simple et le plus fréquent étant le nom de votre fichier, par exemple AUTRE_H si votre fichier se nomme autre.h. Voici un exemple d'utilisation de fichier d'en-tête :

1
2
3
4
5
6
7
8

/* Fichier d'en-tête autre.h */

#ifndef AUTRE_H
#define AUTRE_H

extern int triple(int nombre);

#endif

1
2
3
4
5
6
7
8

/* Fichier source autre.c */

#include "autre.h"

int triple(int nombre)
{
    return nombre * 3;
}

1
2
3
4
5
6
7
8
9

/*Fichier source main.c */

#include "autre.h"

int main(void)
{
    int nombre = triple(3);
    return 0;
}

Plusieurs remarques à propos de ce code :

  • dans la directive d'inclusion, les fichiers d'en-têtes sont entre guillemets et non entre crochets comme les fichiers d'en-têtes de la bibliothèque standard ;
  • les fichiers sources et d'en-têtes correspondants portent le même nom ;
  • je vous conseille d'inclure le fichier d'en-tête dans le fichier source correspondant (dans mon cas autre.h dans autre.c) afin d'éviter des problèmes de portée ;
  • si vous compilez à la main, il vous suffit de spécifier les fichiers sources, les fichiers d'en-têtes seront automatiquement inclus.


Le préprocesseur

On l'a déjà rencontré, on l'a déjà utilisé, mais on ne s'est jamais vraiment intéressé à son fonctionnement ni aux possibilités qu'il nous offre. Je parle bien sûr du préprocesseur. Ce chapitre va vous faire découvrir que le préprocesseur est bien plus puissant que la simple utilisation dont on en a fait jusqu'à maintenant.

  • Le fonctionnement du préprocesseur
  • Une directive : #define
  • Des directives de condition
  • Constantes pré-définies

Le fonctionnement du préprocesseur

Qu'est-ce que le préprocesseur ?

Comme dit en introduction, nous avons déjà rencontré le préprocesseur, sans jamais vraiment s'intéresser à lui. Le préprocesseur est un programme qui réalise des traitements sur le code source avant que ce dernier ne soit réellement compilé. On peut considérer que le préprocesseur a trois rôles (même si c'est quelque peu restrictif).

· Réaliser des inclusions : c'est un cas commun que nous avons déjà vu dès le premier code. Le préprocesseur inclut tout le contenu d'un fichier d'en-tête dans un autre fichier, soit d'en-tête soit source.

· Définir des macros : une macro est un substitut à un morceau de code, qui peut éventuellement prendre des paramètres. Il suffit de définir la macro et de l'utiliser dans le code. Après le passage du préprocesseur, tous les appels à cette macro seront remplacés par le code associé. Si vous êtes habitués au monde de la bureautique et des traitements de texte, vous devez certainement savoir ce que c'est exactement. Nous étudierons les macros dans la deuxième partie.

· Permettre la compilation conditionnelle : le préprocesseur permet également de définir le code à compiler. Cela permet d'écrire du code pour plusieurs plate-formes ou systèmes d'exploitation dans le même fichier source. On peut par exemple imaginer du code spécifique à Linux et à Windows ; dans ce cas, il suffira de préciser lors de l'écriture du code quelle partie du code doit être compilée et le préprocesseur supprimera l'autre. Nous étudierons des exemples dans la troisième partie.

Exemple d'utilisation avec les inclusions

Nous avons vu dès le début du cours comment inclure des fichiers d'en-tête avec la directive #include, mais sans jamais expliquer ce qu'elle faisait. Son but est très simple : inclure le contenu d'un fichier dans un autre fichier. Ainsi, si jamais l'on se retrouve avec deux fichiers comme ceux-ci avant la compilation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

/* Fichier d'en-tête fichier.h */

#ifndef FICHIER_H
#define FICHIER_H

extern int glob_var;

extern void Func1(int);
extern long Func2(double, char);

#endif

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

/* Fichier source fichier.c */

#include "fichier.h"

void Func1(int arg)
{
    /* du code */
}

long Func2(double arg, char c)
{
    /* du code */
}

On ne se retrouve qu'avec un seul fichier à l'arrivée :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

/* Fichier source fichier.c */

extern int glob_var;

extern void Func1(int arg);
extern long Func2(double arg, char c);

void Func1(int arg)
{
    /* du code */
}

long Func2(double arg, char c)
{
    /* du code */
}

On peut voir que le contenu de fichier.h (une variable globale et deux prototypes) a été inclus dans fichier.c et toutes les directives de préprocesseur ont disparu. C'est ce qui se passe chaque fois que l'on inclut un fichier d'en-tête.

D'ailleurs, si vous utilisez gcc, vous pouvez compiler avec l'option -E pour voir le code source après le passage du préprocesseur. Pour ceux qui utilisent Code::Blocks comme IDE, il est possible de faire pareil : allez dans Project -> Build Options -> Other Options et ajouter -save-temps -masm=intel. Cette option sauvegardera tous les fichiers générés par la compilation. Le code après le passage du préprocesseur est contenu dans les fichiers .i.

Mais comme nous l'avons dit, le préprocesseur ne se limite pas à des inclusions. Examinons sans plus tarder ses possibilités.

Une directive : #define

Comme nous l'avons dit dans la partie précédente, le préprocesseur permet la définition de macros, c'est à dire des substituts à des morceaux de code. Pour créer une macro, il faut trois éléments.

  • #define : cette directive va nous permettre de définir une macro.
  • Le nom de la macro : par convention, celui-ci est écrit en majuscules. On peut choisir le nom que l'on veut, à condition de respecter les mêmes règles que pour les variables.
  • La définition : c'est le code que l'on souhaite substituer.

Nous verrons qu'il est également possible de rajouter des paramètres à nos macros.

Des macros simples

Une définition simple

Prenons un exemple très simple : je définis une macro qui substitue l'identificateur TAILLE à la valeur 100.

1
2
3

/* Dans le cas d'une définition simple comme celle-ci, on parle souvent de constante de préprocesseur. */

#define TAILLE 100

Ce code signifie que chaque fois que l'on appellera TAILLE dans le code, le préprocesseur remplacera la macro (TAILLE) par sa définition (100). Voici un code illustrant le principe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>
#define TAILLE 100

int main(void)
{
    int variable = 5;

    /* On multiplie par TAILLE */
    variable *= TAILLE;
    printf("Variable vaut : %d\n", variable);

    /* On additionne TAILLE */
    variable += TAILLE;
    printf("Variable vaut : %d\n", variable);

    return 0;
}

Ce code sera remplacé, après le passage du préprocesseur, par celui ci-dessous.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

int main(void)
{
    int variable = 5;

    variable *= 100;
    printf("Variable vaut : %d\n", variable);

    variable += 100;
    printf("Variable vaut : %d\n", variable);

    return 0;
}

Je n'ai pas inclus le contenu de <stdio.h> car celui-ci est trop long et trop compliqué pour le moment. Néanmoins, l'exemple permet d'illustrer le principe des macros et surtout leur avantage : il suffit de changer la définition de la macro pour que le reste du code s'adapte. Illustrons : on veut changer 100 par 200. Le seul problème est qu'on utilise beaucoup cette valeur, disons 50 fois dans le code. Sans macro, il faudrait changer toutes les valeurs une par une, donc faire 50 modifications. Avec une macro, il suffit de changer la définition et tout le code s'adapte ensuite.

Macros dans d'autres macros

On peut même faire encore mieux : on peut utiliser une macro dans une autre macro. Imaginez que vous souhaitiez calculer le nombre de pixels d'une fenêtre. Vous avez trois macros : la largeur, la hauteur et le total. Il est possible d'appeler les macros LARGEUR et HAUTEUR directement dans la macro TOTAL et ainsi obtenir le nombre de pixels.

1
2
3
4
5

#define HAUTEUR 1440
#define LARGEUR 700
#define TOTAL (HAUTEUR * LARGEUR)

printf("Total = %d pixels\n", TOTAL);

Si j'explicite les étapes, j'obtiens les codes suivants.

1

printf("Total = %d pixels\n", (HAUTEUR * LARGEUR));

1

printf("Total = %d pixels\n", (1440 * 700));

On obtient bien au final le nombre total de pixels. L'avantage ? Non seulement on s'évite des modifications en cas de changement, mais ça va également plus vite à écrire que HAUTEUR * LARGEUR. Les parenthèses ne sont pas obligatoires, mais elles sont fortement recommandées et nous verrons pourquoi par la suite.

Ce qu'il faut retenir c'est qu'il est possible d'utiliser des macros dans d'autres macros, mais également qu'il est possible de faire des opérations (tous les signes mathématiques sont utilisables, c'est à dire +, -, *, / et %).

Allez, exercice ! Faites-moi une macro qui additionne le résultat de deux autres macros. La première macro multiplie un nombre choisi (disons 100) par trois et la deuxième le divise par deux. La correction est sur past.awesom.eu.

D'autres définitions

Jusqu'à maintenant, on s'est contenté de macros qui n'ont pour définition que des valeurs numériques. Fort heureusement, il est possible d'avoir d'autres définitions. Nous venons de voir qu'il était possible qu'une définition soit composée d'une ou plusieurs macros, mais ce n'est pas tout. Elle peut également être composée de caractères voire même de fonctions, de boucles et de conditions. Illustrons cela.

1

#define HELLO  puts("Hello world!")

Je défini la macro HELLO comme étant un substitut du code puts("Hello world!"). Vous noterez que je ne mets pas de point-virgule à la fin de la définition. La plupart des programmeurs font ça par convention. Dans ce cas, il ne faut pas oublier de le rajouter lorsqu'on utilise la macro.

1
2
3
4
5
6
7

#define HELLO  puts("Hello world!")

int main(void)
{
    HELLO;
    return 0;
}

Avec paramètre(s)

Pour l'instant, nous ne faisons que manipuler des macros déjà fixées. Or il serait bien de pouvoir faire comme pour les fonctions, c'est à dire transmettre des paramètres à la macro. C'est possible et c'est même la principale utilisation des macros : agir sur des paramètres. Pour déclarer une macro avec des paramètres, c'est très simple, on rajoute des parenthèses, comme pour les fonctions, en séparant les paramètres par des virgules.

1

#define MACRO(paramètres) définition

Illustrons ce nouveau concept avec un exemple ludique : nous allons écrire deux macros : RAD qui convertira un angle en radian et DGR qui fera l'inverse. Pour ceux qui ne connaissent pas, un radian vaut approximativement 57.29 degrés. Pour passer de l'un à l'autre, on fait ceci :

  • Conversion de $x$ degrés en radians : $\frac{\pi \times x}{180}$
  • Conversion de $x$ radians en degrés : $\frac{180 \times x}{\pi}$

Voici un exemple d'exécution de ce code.

1
2
3
4
5
6
7

Donnez un angle en degrés :
57.29
57.290 degrés = 1.000 radians

Donnez un angle en radians :
3.1415
3.142 radians = 180.024 degrés

Je vous laisse coder cet exemple. Aidez-vous de la correction ici si vous avez vraiment du mal.

Appliquons encore ce concept avec un deuxième exercice : créons la macro MIN qui renvoie le minimum entre deux nombres (exercice bateau classique je l'accorde, dont la correction se trouve ici). Quelque chose a dû vous frapper dans les corrections : pourquoi écrire (x) et pas simplement x ?

Les inconvénients

Évidemment, les macros avec des paramètres présentent des dangers. Le plus gênant est qu'il faut parfois surparenthèser pour éviter toute ambigüité. En effet, si l'on n’y prend pas garde, on peut avoir des surprises dues à la priorité des opérateurs. Prenons l'exemple d'une macro MUL.

1

#define MUL(a, b)  (a * b)

Tel quel, le code peut réserver des surprises. Si en effet j'appelle la macro comme ceci :

1

MUL(2+3, 4+5)

J'obtiens comme résultat 19 (la macro sera remplacée par 2 + 3 * 4 + 5) et non 45, le résultat attendu. Pour garantir la bonne marche de la macro, je dois rajouter des parenthèses.

1

#define MUL(a, b)  ((a) * (b))

Dans ce cas, j'obtiens bien le résultat souhaité, c'est-à-dire 45 ((2 + 3) * (4 + 5)). Je vous conseille de rajouter des parenthèses en cas de doute pour lever toute ambigüité.

Je tiens à terminer cette sous-partie en vous mettant en garde par rapport à un autre danger appelé effet de bord. Un effet de bord est un effet indésirable. Il apparait souvent dans les macros où un paramètre est réutilisé plusieurs fois.

1

#define CARRE(a) ((a) * (a))

Un appel tel que le suivant :

1

CARRE(a++)

entrainera le code suivant :

1

((a++) * (a++))

On remarque que l'opérateur ++ est appliqué deux fois, ce qui rendra le code faux. Faites donc attention quand vous déclarez des macros que celles-ci n'entrainent pas des effets de bord indésirables.

Sur plusieurs lignes

Si jamais notre macro est un peu longue, plutôt que de la définir sur une seule ligne, il est possible de le faire sur plusieurs. On utilise pour cela le symbole \chaque fois que l'on veut aller à la ligne.

1
2
3
4
5
6
7
8
9

#define MACRO  puts("Hello world!"); \
    putchar('a'); \
    printf("%d", 2 + 3)

int main(void)
{
   MACRO;
   return 0;
}

Après le passage du préprocesseur, le fichier source ressemblera à celui ci-dessous.

1
2
3
4
5
6
7
8

int main(void)
{
    puts("Hello world!");
    putchar('a');
    printf("%d", 2 + 3);

    return 0;
}

Cependant, les macros sur plusieurs lignes présentent également un inconvénient : on peut avoir des problèmes d'interprétation, comme le prouve l'exemple suivant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#include <stdio.h>

#define SWAP(a, b, tmp)\
    (tmp) = (a);\
    (a) = (b);\
    (b) = (tmp);

int
main(void)
{
    int a[3]={1,2,3};
    int b[3]={2,4,6};
    int tmp;
    int itr;

    for (itr=0; itr<3; ++itr)
        SWAP(a[itr], b[itr], tmp);

    for (itr=0; itr<3; ++itr)
        printf("%d %d\n",a[itr],b[itr]);

    return 0;
}

Contrairement à ce qui est souhaité, seule la première instruction de la macro fera partie du corps de la boucle, les trois autres seront situés en dehors de celui-ci. En effet, le remplacement produira en vérité le code suivant (notez l'instruction vide à la fin qui résulte de l'ajout d'un point-virgule à la fin de l'appel à la macro SWAP) :

1
2
3
4
5

for (itr=0; itr<3; ++itr)
    (tmp) = (a);
(a) = (b);
(b) = (tmp);
;

Une solution simple à ce problème consiste à utiliser une boucle do { } while. De cette manière, le corps de la macro ne constitue plus qu'une seule instruction avec le point-virgule suivant l'appel à la macro, celui-ci étant nécessaire pour terminer une instruction do {} while (ce qui, finalement, permet d'uniformiser les appels de macro et les appels de fonctions)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

#include <stdio.h>

#define SWAP(a, b, tmp)\
do {\
    (tmp) = (a);\
    (a) = (b);\
    (b) = (tmp);\
} while (0)

int
main(void)
{
    int a[3]={1,2,3};
    int b[3]={2,4,6};
    int tmp;
    int itr;

    for (itr=0; itr<3; ++itr)
        SWAP(a[itr], b[itr], tmp);

    for (itr=0; itr<3; ++itr)
        printf("%d %d\n",a[itr],b[itr]);

    return 0;
}

Des macros sans définition

Comme l'indique le titre, il est possible de créer des macros sans les définir, ce qui fait qu'on créé un substitut à un code inexistant. Exemple :

1

#define MACRO

Ici, MACRO est une macro qui n'a aucune définition, aucun code associé. Pourtant, cette technique est souvent utilisée. Nous allons voir comment dans la partie suivante.

Des directives de condition

Les directives #if, #elif, #else et #endif

Ces quatre directives de préprocesseur font partie de ce que l'on appelle des directives de condition qui permet d'exécuter ou non du code en fonction de la validité d'une condition (si la condition est vraie, le code est exécuté sinon il est ignoré). Les trois premières correspondent à if, else if et else ; la dernière est obligatoire et conclue toute condition.

Voici un exemple très banal dans lequel on teste si une macro est négative, nulle ou positive.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>

#define A   2

#if (A) < 0
#define B puts("A < 0")
#elif (A) > 0
#define B puts("A > 0")
#else
#define B puts("A == 0")
#endif

int main(void)
{
    B;
    return 0;
}

defined

Cette directive, associée à celles ci-dessus, permet de tester l'existence ou la non-existence d'une constante. Le principe est simple : si la constante à tester est déjà définie, on exécute le code associé, sinon il est supprimé. Cette technique est utilisé pour produire des programmes portables. En effet, chaque système et chaque compilateur définissent une constante qui lui est propre. On a donc une constante pour Windows, une pour Mac, une pour Linux, mais également des constantes pour gcc, Visual Studio, MinGW, etc. En testant si une constante existe on peut déterminer sur quelle plate-forme et avec quel compilateur on compile et adapter le code en conséquence. Il est cependant important de noter que ces constantes ne sont pas standards. Autrement dit, elles peuvent varier selon plusieurs paramètres. Renseignez-vous bien lorsque vous voudrez les utiliser.

Voici un exemple de code qui teste si l'on est sous Windows 32 ou 64 bits ou bien sous Linux ou MacOS pour inclure un fichier d'en-tête spécifique au système d'exploitation cible (celui-ci étant le même pour Linux et MasOS).

1
2
3
4
5
6
7
8
9

/* si on est sous Mac ou Linux */
#if defined __APPLE__ || defined linux
#include <unistd.h>

/* ou bien si on est sous Windows 32 bits ou 64 bits */
#elif defined __WIN32__ || defined __WIN64__
#include <windows.h>

#endif

Bien entendu, les autres opérateurs de conditions sont eux aussi utilisables.

Les directives #ifdef et #ifndef

La première directive signifie « si la constante est définie », la deuxième étant son contraire, « si la constante n'est pas définie ». Elles sont en quelque sorte des "raccourcis" de defined. En effet, si vous n'avez qu'une seule constante à tester, il est plus rapide d'utiliser ces deux directives que defined. Illustrations avec un code basique dans lequel on ne teste que pour Windows et Linux.

1
2
3
4
5
6
7

#ifdef __WIN32__  /* à la place de #if defined __WIN32__ */
#include <windows.h>
#endif

#ifdef linux  /* à la place de #elif defined linux */
#include <unistd.h>
#endif

Sécurisation d'un fichier d'en-tête

L'avantage de cette technique est que l'on peut sécuriser les fichiers d'en-tête. En effet, lorsque l'on créé un fichier d'en-tête, il faut toujours faire attention aux inclusions infinies. Cela peut arriver quand un fichier d'en-tête 1 inclut un fichier d'en-tête 2 qui lui-même inclut 1. Le préprocesseur rentre dans une sorte de boucle infinie qui fait au final planter la compilation. Pour éviter ces cas, je vous avais fourni un code à mettre obligatoirement à chaque création de fichier d'en-tête. Il est maintenant temps d'expliquer exactement son fonctionnement. Je vous remets le code en dessous ; sauriez-vous l'expliquer sans regarder la réponse ?

1
2
3
4

#ifndef FICHIER_H
#define FICHIER_H

#endif
  • #ifndef FICHIER_H : si la constante associée au fichier n'a jamais été défini, le préprocesseur rentre dans la condition et poursuit l'exécution du code.
  • #define FICHIER_H : on définit la constante du fichier. La prochaine fois, la condition sera fausse et le fichier ne sera pas inclus.
  • #endif : termine la condition.

Ce code est simple, mais il permet d'éviter des inclusions infinies et donc des problèmes. Je vous conseille donc très fortement de l'utiliser chaque fois que vous créez un fichier d'en-tête.

Constantes pré-définies

Macros standards

La norme prévoit que quatre constantes de préprocesseur sont définies (cf norme C89 - 3.8.8 Predefined macro names). Ces quatre macros sont __LINE__ (le numéro de la ligne actuelle), __FILE__ (le nom de fichier courant), __DATE__ (la date de compilation du fichier, sous la forme Mois / Jour / Année) et __TIME__ (l'heure de la compilation, sous la forme hh:mm:ss). Les trois premieres sont des chaines de caractères, la dernière un entier.

Je vous invite à tester le code ci-dessous chez vous.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

#include <stdio.h>

int main(void)
{
    puts(__FILE__);
    puts(__DATE__);
    puts(__TIME__);
    printf("%d\n", __LINE__);

    return 0;
}

Détecter le compilateur et le système

Je tiens à conclure cette partie en vous offrant une liste de quelques constantes courantes. Bien entendu, cette liste n'est pas exhaustive, mais elle contient la plupart des constantes servant à définir le système d'exploitation et / ou le compilateur. Je le répète encore une fois : ces constantes ne sont pas standards et peuvent donc varier.

Systèmes d'exploitation

  • Windows : _WIN32 (32 bits) ou _WIN64 (64 bits)
  • GNU/Linux : Linux ou __linux__
  • Apple : __APPLE__ ou __MACH__
  • FreeBSD : __FreeBSD__
  • NetBSD : __NetBSD__
  • Solaris : sun ou __SVR4

Compilateurs

  • Visual C++ : _MSC_VER
  • GCC : versions : __GNUC__, __GNUC_MINOR__ ou __GNUC_PATCHLEVEL__
  • Borland : __TURBOC__ ou __BORLANDC__
  • MinGW : __MINGW32__
  • Cygwin : __CYGWIN__ ou __CYGWIN32__

Il existe encore beaucoup d'autres constantes prédéfinies, comme pour connaitre la version du compilateur ou le nom de la fonction courante par exemple. Je ne les mets pas ici car elles sont très nombreuses, mais vous en connaissez l'existence, si jamais il y a besoin un jour.


L'en-tête <math.h>

Dans les chapitres précédents, on a vu comment bien organiser notre code avec de fonctions. On a aussi vu comment regrouper ces fonctions de façon cohérente dans des fichiers séparés, en utilisant des fichiers d’en-tête. Cela nous mène directement au concept de bibliothèque. Ce sont des fichiers dans lesquelles on va regrouper des fonctions, qu'on pourra réutiliser au besoin.

Ces bibliothèques peuvent contenir toute sorte de fonctions : fonctions mathématiques, fonctions de gestion de la mémoire, etc. Le langage C normalise quelques bibliothèques de base, qui seront disponibles avec n"importe quel compilateur sous n'importe quel système d'exploitation (à condition de ne pas utiliser un environnement trop exotique). On peut notamment citer la bibliothèque <math.h>, qui rassemble quelques fonctions mathématiques. Dans ce chapitre, nous allons découvrir ce que cette bibliothèque a dans le ventre !

NB : il se peut que vous n'ayez pas le niveau mathématique pour tout comprendre. Dans ce cas, lisez quand même le chapitre, même si vous ne comprenez pas tout.

  • Les bibliothèques
  • Découvrir <math.h>
  • TP : Recodons des fonctions mathématiques

Les bibliothèques

Les bibliothèques en informatique

Je tiens à attirer votre attention sur le fait que la bibliothèque mathématique du langage C est une bibliothèque un peu spéciale. Celle-ci se comporte comme une bibliothèque tierce, soit une bibliothèque qui se rajoute par-dessus la bibliothèque standard. Les bibliothèques peuvent être présentés sous forme de code source ou de fichiers binaires à lier à vos programmes.

Dans les grandes lignes, il existe deux grand types de bibliothèques : les bibliothèques statiques, et les bibliothèques dynamiques. Avec une bibliothèque statique, le code de la bibliothèque est inclus dans l'exécutable lors de la compilation. Ce dernier est donc plus important, il prend plus de place. Une bibliothèque dynamique est un fichier utilisé par un exécutable, mais n'en faisant pas partie. Ce fichier contient des fonctions qui pourront être appelées pendant l'exécution d'un programme, sans que celles-ci soient incluses dans son exécutable. Il faut dans ce cas fournir la bibliothèque avec le programme.

Les systèmes d'exploitations récents sont pratiquement tous multitâche : ils permettent d’exécuter plusieurs programmes "en même temps". Il arrive donc que certains programmes utilisent les mêmes bibliothèques en même temps. Avec des bibliothèques dynamiques, il est possible de ne charger celles-ci qu'une seule fois en mémoire et de laisser tous les programmes en utiliser la même copie. On parle alors de bibliothèques partagées.

Ces bibliothèques sont donc des fichiers binaires, qui possèdent généralement les extensions suivantes :

  • Bibliothèques statiques :
    • .a : sous UNIX et GNU/Linux (pour Archive) ;
    • .lib : sous Microsoft Windows (LIBrary).
  • Bibliothèques dynamiques :
    • .so : sous UNIX et GNU/Linux (Shared Object) ;
    • .dylib : sous Mac OS X (Dynamic Library) ;
    • .dll : sous Microsoft Windows (Dynamically Linkable Libraries).

Notre fameuse bibliothèque mathématique se nomme libm et est une bibliothèque statique. Celle-ci ne se lie au programme que sous les systèmes de type UNIX et GNU/Linux (Windows le fait automatiquement).

Lier une bibliothèque

À savoir, il est courant de voir sur la toile la traduction anglaise du verbe lier : link. Certains parlent même de "linkage d'une bibliothèque", et utilisent l'expression "J'ai linké la bibliothèque mathématique". En bon francophiles, nous allons utiliser le terme lier par la suite.

Afin de lier une bibliothèque sous GCC et MinGW, on procède ainsi :

1

-l<bibliothèque>

Si vous compilez en ligne de commande, cela donnera dans notre cas :

1

gcc main.c -lm    # m comme mathématiques

Pour lier une bibliothèque, il faut d'abord mettre les fichiers au bon endroit. S'il s'agit d'une bibliothèque statique, il faut la mettre dans le bon dossier. Nous avons alors deux choix. Le premier, c'est de mettre tous les fichiers dans le dossier du compilateur.

  • Avec Code::Blocks, il faut la mettre dans le dossier lib situé dans le répertoire du compilateur.
  • Avec Visual C++, il faut la mettre dans le dossier C:\Program Files (x86)\Microsoft Visual Studio xx.x\VC\lib.
  • Avec xCode, il faut la mettre dans le dossier <Racine Disque>/Bibliothèque/Frameworks.
  • Pour ceux qui compilent à la main, il suffit d'ajouter les fichiers dans le dossier lib du compilateur.

Ça semble logique, mais c'est assez embêtant : on se retrouve avec des dossiers remplis de bibliothèques dont on ne sait plus d'où elles viennent, désinstaller un logiciel demande de sauvegarder toutes les bibliothèques installées, etc. Bref, pas très pratique. Pour pallier à ces problèmes, il y a une deuxième solution : on crée un dossier indépendant, dans lequel on crée un sous-dossier par bibliothèque. Cela permet de bien organiser ses fichiers, d'être indépendants des logiciels utilisés et est plus facile à entretenir. Le choix de la méthode est votre.

Si au contraire il s'agit d'une bibliothèque dynamique, c'est tout simple, celle-ci doit être mise dans le répertoire du projet.

Ensuite, il faut modifier les propriétés du projet pour qu'il lie bien la bibliothèque au projet. La méthode varie selon que vous êtes avec :

  • Code::Blocks : Ouvrez ou créez un projet puis allez dans Project -> Build options -> Linker settings -> Other linker options, insérez ensuite tout le texte nécessaire dans la zone de texte.
  • Visual Studio : Il suffit de rajouter la ligne suivante chaque fois que vous voulez lier une bibliothèque, en remplaçant non_de_la_lib par le nom de la bibliothèque à linker.
1

#pragma comment (lib, "nom_de_la_lib.lib")

Il est également possible de l'ajouter comme élément du projet.

Découvrir <math.h>

Nous allons maintenant étudier la bibliothèque mathématiques du langage C afin que vous sachiez quoi faire par la suite dans le TP. Le fichier d'en-tête de cette bibliothèque se nomme <math.h> contient un certain nombre de déclarations de fonctions mathématiques ainsi qu'une macro. Vous le savez, tout comme <stdio.h> ou <stdlib.h>, on inclue le fichier d'en-tête mathématiques de cette manière :

1

#include <math.h>

La norme C89 précise que la bibliothèque mathématique est composée d'une macro et de 22 fonctions. Cependant, je vous présenterai seulement les fonctions les plus connues et les plus utiles.

Une macro utile

Pour commencer, nous allons rapidement parler de la macro HUGE_VAL, qui représente la valeur de type double la plus élevée que l'ordinateur peut gérer. Cette valeur dépend de la machine de l'utilisateur. Cette macro est principalement utilisée lors des retours de fonctions afin de savoir si ces dernières se sont bien exécutées.

Sinus, cosinus et tangente

Les premières fonctions disponibles dans cette bibliothèque sont les fonctions trigonométriques sinus, cosinus, et tangente. Cela doit vous rappeler quelque chose que vous avez vu dans vos cours de mathématiques. Les prototypes de ces trois fonctions sont les suivants :

  • double sin(double x);
  • double cos(double x);
  • double tan(double x);

Ces fonctions prennent un nombre réel en radians et retournent un résultat appartenant à l'intervalle $[-1 ; +1]$

Voici un exemple d'utilisation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

#include <stdio.h>
#include <math.h>

int main(void)
{
    double x, y, z;

    x = sin(0.028);
    y = cos(0.028);
    z = tan(0.028);

    printf("x = %.10f\ny = %.10f\nz = %.10f", x, y, z);
    return 0;
}

1
2
3

x = 0.0279963415
y = 0.9996080256
z = 0.0280073196

Comme vous pouvez le voir, j'ai indiqué à la fonction printf la précision, c'est-à-dire le nombre de chiffres après la virgule que je souhaite afficher.

Mesurer un angle

Si les fonctions sinus, cosinus et tangente sont disponibles, c'est aussi le cas de leurs fonctions réciproques, à savoir arc-sinus, arc-cosinus et arc-tangente :

  • double asin(double x);
  • double acos(double x);
  • double atan(double x);

Ces fonctions attendent un paramètre appartenant à $ [-1 ; +1]$ et retournent un résultat appartenant à $\left[ frac{-\pi}{2} ; frac{+\pi}{2} \right]$.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x, y, z;

    x = asin(0.0279963415);
    y = acos(0.9996080256);
    z = atan(0.0280073196);

    printf("x = %f\ny = %f\nz = %f\n", x, y, z);
    return 0;
}

1
2
3

x = 0.028000
y = 0.028000
z = 0.028000

Logarithmes

Commençons par les fonctions concernant les logarithmes. Je nomme log et log10 :

  • double log(double x);
  • double log10(double x);

Comme la plupart des fonctions de la bibliothèque mathématique, elles prennent comme argument un double et renvoient un double<. La fonction log renvoie le logarithme népérien du nombre passé en paramètre, alors que log10 renvoie le logarithme en base 10. Bien entendu, le paramètre doit appartenir à $] 0 ; +\infty [$ ; la fonction retourne alors un résultat appartenant à $\mathbb{R}$.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x, y, z;

    x = log(4242);
    y = log10(4242);
    z = log10(1000000);

    printf("x = %f\ny = %f\nz = %f\n", x, y, z);
    return 0;
}

1
2
3

x = 8.352790
y = 3.627571
z = 6.000000

Exponentielle

Passons à la fonction exp, qui prend un nombre réel et renvoie un résultat appartenant à $] 0 ; +\infty [$. Simple précaution : cette fonction ayant une croissance très rapide, attention si vous manipuler de grands nombres à ne pas dépasser la capacité maximale d'un double.

1

double exp(double x);

1
2
3
4
5
6
7
8
9

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x = exp(3);
    printf("x = %f\n", x);
    return 0;
}

1

x = 20.085537

Puissances

Étudions maintenant la fonction pow.

1

double pow(double x, double y);

Elle élève le nombre x à la puissance y ($x^y$). Cette fonction est très lourde et lente à l’exécution, je vous recommande donc de ne pas l'utiliser pour les calculs de faibles puissances. Mais, pourquoi cette fonction est-elle si lente ? La raison est simple : elle est très puissante, puisque elle gère les nombres et les exposant négatifs, ce qui n'est pas une mince affaire.

Cependant, il existe quelques cas que pow est incapable de gérer.

  • Il n'est pas possible de lui fournir un nombre négatif et un exposant fractionnaire sans quoi vous lui demandez de calculer la racine d'un nombre négatif (ce qui est impossible dans le cas de nombres réels).
  • Il n'est pas possible de lui passer un nombre nul et un exposant nul en même temps ($0^0$ n'est pas calculable).
  • Il n'est pas possible de lui transmettre un nombre nul et un exposant négatif sans quoi vous lui demandez de diviser un nombre par zéro (ce qui est mathématiquement impossible).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x, y, z;

    x = pow(5, 2);
    y = pow(2, -2);
    z = pow(3.14, 4);

    printf("x = %f\ny = %f\nz = %f\n", x, y, z);
    return 0;
}

1
2
3

x = 25.000000
y = 0.250000
z = 97.211712

Racine carrée

L'en-tête <math.h> fournit aussi une fonction de calcul de la racine carrée d'un nombre. Ils 'agit de la fonction sqrt. Son prototype est le suivant :

1

double sqrt(double x);

La fonction sqrt renvoie la racine carrée du réel positif passé en paramètre. Elle renverra une erreur si vous tentez de calculer la racine carrée d'un réel négatif.

1
2
3
4
5
6
7
8
9

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x = sqrt(25);
    printf("x = %f\n", x);
    return 0;
}

1

x = 5.000000

Fonctions d’arrondis

Cette rapide présentation de la bibliothèque mathématiques standard du C touche à sa fin, il ne nous reste plus qu'une poignée de fonctions à voir, notamment les fonctions d'arrondis.

  • double ceil(double x);
  • double floor(double x);

La première, ceil, renvoie la partie entière supérieure du nombre x. À l'inverse, la fonction floor la partie entière inférieure du nombre passé en paramètre. Voici un petit code d'exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x, y;

    x = ceil(42.7);
    y = floor(42.7);

    printf("x = %f\ny = %f\n", x, y);
    return 0;
}

1
2

x = 43.000000
y = 42.000000

Autres fonctions sur les flottants

Il nous reste ensuite quelques fonctions restantes, inclassables. J'ai nommé :

  • double fabs(double x);
  • double fmod(double x, double y);

La fonction fabs retourne la valeur absolue de x, soit $|x|$. Pour ceux qui ne connaissent pas, la valeur absolue d'un nombre représente la partie numérique de ce dernier.

$|1| = 1$
$|-42|= 42$

Pour finir la fonction fmod quand à elle, divise x par y puis renvoie le reste. Pour simplifier, elle effectue un modulo sur deux flottants. Elle est utile car le langage C ne nous autorise pas à utiliser l'opérateur % sur des flottants.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <math.h>
#include <stdio.h>

int main(void)
{
    double x, y;

    x = fabs(-7);
    y = fmod(42, 5);

    printf("x = %f\ny = %f\n", x, y);
    return 0;
}

1
2

x = 7.000000
y = 2.000000

TP : Recodons des fonctions mathématiques

Fini la théorie, place à la pratique ! Votre mission, si vous l'acceptez : recoder quelques fonctions de la bibliothèques mathématiques, que vous connaissez bien désormais. Bien entendu, je vous aiderai à chaque fois en vous expliquant l’algorithme à utiliser par exemple. Enfin, avant de commencer, je vous livre une petite astuce : pour tester si votre fonction fonctionne correctement, pourquoi ne pas la comparer avec celle de la bibliothèque standard ? Et pour ce faire, pourquoi ne pas créer une macro qui donnerait le résultat retourné par votre fonction et par celle de la bibliothèque standard ?

Racine carrée

La première fonction que nous allons recoder est la racine carrée d'un réel positif. Pour cela, nous allons utiliser un très vieil algorithme appelé la méthode de Héron, du nom du mathématicien grec qui l'a découverte.

Cette méthode est relativement simple : pour trouver $\sqrt{A}$, on prend un nombre au hasard qu'on nomme $x_{n}$, et on utilise cette formule : $x_{n+1} = \frac{x_n + \frac{A}{x_n}}{2}$. Il suffit de répéter cette opération plusieurs fois, en remplaçant $x_{n}$ par la valeur calculée au tour d'avant. En faisant ainsi, le résultat converge vers $\sqrt{A}$, la précision dépendant du nombre de répétitions de la récurrence. On doit calculer cette suite jusqu’à ce qu'on aie atteint une précision suffisante.

Vous avez maintenant toutes les clefs en main pour recoder la fonction sqrt. Si vous avez du mal, jetez un œil à l'algorithme se trouvant ici. Quant à ceux qui veulent comparer leur code à la correction, en voici une. Testez donc si les valeurs retournées sont correctes.

Exponentielle

La deuxième fonction que nous allons faire sera l'exponentielle de base $e$, notée $\exp(x)$ ou $e^x$. Une implémentation naïve serait d'utiliser la fonction pow. C'est une idée, facile et rapide à coder, mais pas très optimisée : en effet, pow est une fonction assez lourde, autant choisir une autre solution : le développement limité.

La formule est la suivante : $e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \ldots+ \frac{x^n}{n!} + o(x^n)$. En gros, on doit calculer cette somme jusqu’à ce qu'on aie atteint une précision suffisante. Pour ceux qui ne savent pas, $n!$ est la factorielle d'un nombre et vaut $1 \times 2 \times 3 \times \ldots \times (n - 1) \times n$ soit $\prod_{i = 0}^n i$.

Avant de vous lancer, je vous donne une dernière astuce : vous n'avez pas besoin de la fonction pow, ni de faire une fonction calculant la factorielle d'un nombre. Bon courage, vous pouvez y arriver !

[secret]{ Si vous ne voyez pas comment se passer de pow ou d'une fonction factorielle, dîtes-vous qu'il n'est pas nécessaire de recalculer toutes les puissances à chaque fois. En effet, $x^{n+1} = x^n \times x$. Il suffit de connaître $x^n$. Le principe est le même pour la factorielle : $(x+1)! = x! \times (x + 1)$. }

Pour ceux qui auraient besoin, l'algorithme est disponible à cette adresse. Enfin, pour ceux qui ont fini ou qui veulent s'aider de la correction, elle se trouve ici.

Autres

Pour votre information, le calcul des fonctions logarithme, sinus, cosinus, tangente, et leurs réciproque, peut s'effectuer aussi avec des développements limités. Les formules sont facilement trouvables sur la toile : Wikipédia est votre allié.


Les pointeurs

Bon, dans les chapitres précédents, on a vu pas mal de choses : comment créer et utiliser des variables, des structures de contrôle, ou des fonctions. Ces opérations sont assez basiques, et on n'est pas vraiment rentré dans le vif du sujet. Mais maintenant, cela va changer !

Nous allons aborder les pointeurs, un mécanisme du langage C qui fait peur à certains. Pourtant, elle est derrière une grande partie des fonctionnalités des langages de programmation actuels : on en trouve partout ! Le C n'échappe pas à cette règle, et les pointeurs sont une fonctionnalité majeure du langage. Mais contrairement au langage C, la plupart des langages les cachent et les camouflent plus ou moins efficacement. Il faut dire que les pointeurs ont une très mauvaise réputation : ils seraient difficiles à comprendre, difficiles à maitriser, et d'une complexité qui ferait fuir tous les débutants. Mais il s'agit bien évidemment d'idées reçues.

La raison à cette mauvaise réputation qu'ont les pointeurs est simple : les pointeurs sont ce qu'on appelle une fonctionnalité bas niveau. En clair, il s'agit d'un mécanisme qui nécessite de comprendre quelques détails sur le fonctionnement d'un ordinateur pour être comprise ou utilisée correctement. En effet, pour comprendre les pointeurs, il suffit de comprendre un tout petit peu ce qu'est la mémoire RAM. Rien de bien compliqué : seuls quelques rappels suffiront. Vous allez voir, contrairement à ce qu'on pense, ces pointeurs ne sont pas compliqués : c'est même le contraire !

  • C'est quoi ?
  • Utilisation
  • Exercice

C'est quoi ?

Tout d'abord, nous allons devoir parler de l'utilité de ces pointeurs. Et quelques rappels sont nécessaires.

Utilité

Comme nous l'avons vu dans le chapitre sur les variables, une donnée est stockée dans une mémoire bien particulière de notre ordinateur : registre, RAM, etc. Seulement, pour pouvoir utiliser cette donnée, nous avons besoin de savoir où elle se situe exactement dans la mémoire. Ce moyen s'appelle une référence.

Nos variables simples, comme des int, float, double, ou char individuels sont directement gérées par le compilateur, qui les charge le plus souvent dans les registres. On n'a pas besoin de manipuler de références vers ces derniers : les noms de variables suffisent pour le programmeur.

Créations de structures de données

Mais pour les autres données, c'est une autre histoire. Eh oui : en C, il est possible d'utiliser des données plus grosses que des simples variables. Ces données complexes sont crées en regroupant ensemble des variables de type int, float, double, ou char.

L'ensemble est souvent trop gros pour être stocké dans des registres, et est placé en mémoire RAM. Conséquence : on ne peut pas manipuler ces regroupements directement, et on est obligé de manipuler les variables élémentaires unes par unes, ce qui demande de connaitre la position en RAM de celles-ci.

Allocation dynamique

Enfin, il faut savoir qu'il est possible de réserver temporairement une portion inutilisée de la mémoire pour stocker temporairement des données ou des variables. Quand on n'a plus besoin de cette portion de mémoire, il la libère, et elle sera réutilisable à volonté. C'est ce qu'on appelle l'allocation dynamique. Le seul problème, c'est que l'emplacement de la portion de mémoire change à chaque réservation. On doit donc se souvenir de cet emplacement, sans quoi le manipuler sera impossible. Et c'est encore une fois au programmeur de gérer cela, du moins en partie.

Passage de données complexes en argument de fonctions

Autre exemple : si je veux qu'une fonction manipule une donnée, je peux passer celle-ci en argument de ma fonction. Elle sera alors recopiée, et ma fonction manipulera la copie. Dans certains cas, on voudra programmer une fonction qui modifie directement l'original et non une copie. Mais pour envoyer l'original à notre fonction, on doit dire à notre fonction où est l'original dans la mémoire.

La RAM

Bref, c'est (entre autres) à cela que servent les pointeurs : ils servent à donner l'emplacement en mémoire RAM d'une donnée précise pour qu'on puisse la manipuler. Reste à savoir comment ils font.

Dans notre RAM, les données sont découpées en "paquets" contenant une quantité fixe de bits : des "cases mémoires", aussi appelées bytes. Généralement, nos mémoires utilisent un byte de 8 bits, aussi appelé octet. Avec un octet, on peut stocker 256 informations différentes. Pour stocker plus d’informations (par exemple les nombres de -1024 à 1023), on peut utiliser plusieurs octets, et répartir nos informations dedans. Nos données peuvent prendre un ou plusieurs octets qui se suivent en mémoire, sans que cela pose problème : nos mémoires et nos processeurs sont conçus pour gérer ce genre de situations facilement. En effet, nos processeurs peuvent parfaitement aller lire 1, 2, 3, 4, etc. octets consécutifs d'un seul coup sans problème, et les manipuler en une seule fois. Mais cela ne marche que pour des données simples.

Chacun de ces octets se voit attribuer un nombre unique, l'adresse, qui va permettre de la sélectionner et de l'identifier celle-ci parmi toutes les autres. Il faut imaginer la mémoire RAM de l'ordinateur comme une immense armoire, qui contiendrait beaucoup de tiroirs (les cases mémoires) pouvant chacun contenir un octet. Chaque tiroir se voit attribuer un numéro pour le reconnaitre parmi tous les autres. On pourra ainsi dire : je veux le contenu du tiroir numéro 27 ! Pour la mémoire c'est pareil. Chaque case mémoire a un numéro : son adresse.

Adresse | Contenu mémoire
0 | 11101010 01011010
1 | 01111111 01110010<
2 | 00000000 01111100
3 | 01010101 0000000<
4 | 10101010 00001111
5 | 00000000 11000011

En fait, on peut comparer une adresse à un numéro de téléphone (ou à une adresse d'appartement) : chacun de vos correspondants a un numéro de téléphone et vous savez que pour appeler telle personne, vous devez composer tel numéro. Ben les adresses mémoires, c'est pareil !

Exemple : on demande à notre mémoire de sélectionner la case mémoire d'adresse 1002 et on récupère son contenu (ici, 17)

Pointeurs

Nous y voilà : ce qu'il nous faut, c'est donc réussir à stocker l'adresse mémoire à laquelle commence une donnée, et la manipuler comme bon nous semble. Pour cela, on a inventé les pointeurs : ce sont des variables dont le contenu est une adresse mémoire.

Exemple, avec une variable a qui est un pointeur sur une variable b

C'est aussi simple que cela. Leur utilité ne semble pas évidente au premier abord, mais sachez que cela deviendra plus clair après quelques exemples. De plus, nous utiliserons beaucoup les notions vues dans ce chapitre une fois qu'on parlera des tableaux et des structures de données. Cela ne se voit pas au premier abord, mais les pointeurs sont importants dans le langage C.

Utilisation

Avant de rentrer dans le vif du sujet, il faut encore faire quelques remarques sur nos fameux pointeurs. Cette fois-ci, on va rentrer vraiment dans la pratique : finie la théorie, on va parler de choses spécifiques au langage C.

En C, les pointeurs sont typés : les pointeurs qui stockent l'adresse d'un int ou d'un char seront différents. C'est comme ça, les pointeurs vont stocker les adresses mémoires d'une donnée simple, qui peut être un int, un char, un float ou un double. Le type du pointeur sert à préciser si l'adresse contenue dans ce pointeur est l'adresse d'un int, d'un char, d'un float, etc.

Vous vous demandez surement à quoi peut bien servir cette bizarrerie. Tout ce que je peux dire pour le moment, c'est que c'est dû au fait que des données de type différent n'occuperont pas la même quantité de mémoire : suivant la machine, un int peut ne pas avoir la même taille qu'un short, par exemple. Pour éviter les ennuis lors des manipulations de pointeurs, les pointeurs sont donc typés. Vous verrez quand on abordera l'arithmétique des pointeurs : on devra calculer des adresses mémoires, et la taille des types jouera beaucoup lors de ces calculs. Vous comprendrez alors l'utilité du typage des pointeurs.

Déclaration

Pour déclarer un pointeur, il suffit de lui donner un nom, et de préciser son type, c'est-à-dire le type de la donnée dont on stocke l'adresse dans le pointeur. La syntaxe de la déclaration est celle-ci :

1

type * nom_du_pointeur;

Par exemple, si je veux créer un pointeur sur un int (c'est-à-dire un pointeur pouvant stocker l'adresse d'une variable de type int) et que je veux le nommer ptr, je devrais écrire ceci :

1

int * ptr;

Un pointeur particulier

Nous venons de dire qu'un pointeur devait forcément pointer sur un objet d'un type donné. A priori, il semble impossible de faire un pointeur qui pourrait pointer sur n'importe quel type d'objet sans problème. Pourtant, les concepteurs de la norme C89 ont pensé à nous : ils ont introduit un pointeur un peu spécial appelé pointeur universel : void *. Ce dernier peut se voir assigner n'importe quel type de pointeur et peut lui-même être converti en n'importe quel type de pointeur.

1
2
3

int * p = 0;
void * uni = p; /* (int *) -> (void *) */
int * q = uni; /* (void *) -> (int *) */

Dans cet exemple, le pointeur uni se voit assigner un pointeur sur int et est ensuite assigner au pointeur q sans que cela pose le moindre problème. Faites attention cependant : un pointeur générique ne peut pas être déréférencé (voyez la section qui y est dédiée un peu plus bas), il doit faire l'objet d'une conversion explicite auparavant.

Pour la touche culturelle, sachez qu'avant l'apparition de la norme C89 le pointeur universel n'existait pas. On utilisait à la place un pointeur sur char.

Référencement

Le référencement est une opération qui permet de récupérer l'adresse d'un objet. Pour se faire, il suffit de placer l'opérateur & devant la variable dont on souhaite récupérer l'adresse. Pour l'afficher, on utilise printf avec un nouvel indicateur de conversion : %p qui affiche le résultat en hexadécimal. Il y a cependant une petite contrainte : il faut convertir le pointeur en void*.

1
2

int ma_variable;
printf("Adresse de ma_variable : %p\n", (void *)&ma_variable);

1

Adresse de ma_variable : 0x7fff9ee178ac

Cette valeur change à chaque exécution, ne vous inquiétez pas si vous n'obtenez pas le même résultat que moi.

Initialisation et pointeur nul

Un pointeur, comme une variable, ne possède pas de valeur par défaut. Il pointe donc sur n'importe quoi, n'importe où en mémoire. Il est donc important de l'initialiser pour éviter d'éventuels problèmes. Pour se faire, il y a deux possibilités : soit l'initialiser à une valeur nulle, soit l'initialiser avec une adresse.

Initialisation avec une adresse valide

Pour commencer, on peut initialiser un pointeur avec une adresse, que ce soit celle d'une variable ou le retour d'une fonction renvoyant un pointeur.

1
2
3

int ma_variable = 10;
int *mon_pointeur;
mon_pointeur = &ma_variable;

Ou même ainsi :

1
2

int ma_variable = 10;
int *mon_pointeur = &ma_variable;

Pointeur NULL

Mais il arrive qu'on doive déclarer un pointeur sans savoir quelle adresse mettre dedans, cette adresse étant disponible plus tard, plus loin dans le code. Dans ce cas, on peut donner une valeur particulière au pointeur pour faire en sorte qu'il ne pointe nulle part et qu'il soit considéré comme invalide. Cette valeur particulière s'appelle NULL.

1
2

/* maintenant ptr est un pointeur invalide */
int * ptr = NULL;

Déréférencement

S'il est possible de récupérer l'adresse d'un objet grâce au référencement, il est également possible d'accéder et de modifier cet objet grâce au déréférencement. Sans cette possibilité, les pointeurs n'auraient aucune utilité.

Cette opération se fait avec l’opérateur *. Avec la syntaxe *mon_pointeur, nous avons accès à la donnée contenue à l'adresse de mon_pointeur aussi bien en lecture qu'en écriture. C'est le cas dans le premier exemple ci-dessous où l'on affecte à une variable la valeur du contenu du pointeur, c'est-à-dire la valeur de ma_variable.

1
2
3
4
5
6
7

int a = 0;
int ma_variable = 10;
int *mon_pointeur = &ma_variable;

a = *mon_pointeur ;

printf("Valeur contenue a l'adresse '%p' : %d\n", (void *)mon_pointeur, a);

1

Valeur contenue à l'adresse '0x7ffa2ee591a7' : 10

Dans le deuxième exemple, on modifie la valeur de l'objet pointé par mon_pointeur, c'est à dire qu'indirectement on modifie ma_variable.

1
2
3
4
5
6
7
8

int ma_variable = 10;
int *mon_pointeur = &ma_variable;

printf("Valeur contenue à l'adresse '%p' : %d\n", (void *)mon_pointeur, *mon_pointeur);

*mon_pointeur = 20;

printf("Valeur contenue à l'adresse '%p' : %d\n", (void *)mon_pointeur, *mon_pointeur);

1
2

Valeur contenue à l'adresse '0028FF18' : 10
Valeur contenue à l'adresse '0028FF18' : 20

Des pointeurs comme arguments dans des fonctions

Il existe deux manières d'utiliser les pointeurs avec les fonctions. Voici la première :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

#include <stdio.h>

void ma_fonction(int *mon_pointeur)
{
    *mon_pointeur = 20;
}

int main(void)
{
    int ma_variable = 10;
    ma_fonction(&ma_variable);

    printf("Valeur de ma_variable : %d\n", ma_variable);
    return 0;
}

Là, il n'y a rien de très difficile : on envoie l'adresse de ma_variable à ma_fonction puis celle-ci modifie la valeur de ma_variable directement en mémoire par l'intermédiaire de mon_pointeur.

Quel est l'intérêt d'utiliser les pointeurs à la place de return ? On peut par exemple modifier la valeur de plusieurs variables dans une fonction au lieu de ne pouvoir en modifier qu'une qu'on retourne.

Passons maintenant à la deuxième technique pour envoyer un ou plusieurs pointeurs à une fonction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

#include <stdio.h>

void ma_fonction(int *mon_pointeur)
{
    *mon_pointeur = 20;
}

int main(void)
{
    int ma_variable = 10;
    int *mon_pointeur = &ma_variable;

    ma_fonction(mon_pointeur);

    printf("Valeur de ma_variable : %d\n", ma_variable);
    printf("Valeur contenue a l'adresse de mon_pointeur : %d\n", *mon_pointeur);
    return 0;
}

Sans exécuter ce code, essayez de deviner quel sera le résultat de ces deux printf. Trouvé ? Si ce n'est pas le cas, relisez entièrement et très attentivement ce chapitre. Voici le résultat attendu :

1
2

Valeur de ma_variable : 20
Valeur contenue à l'adresse de mon_pointeur : 20

Un grand bravo à tous ce qui ont compris cela dès le premier coup ! Pour les autres, ne vous en faites pas, ça va venir petit à petit.

Exercice

Pour le moment, vous vous dites surement que les pointeurs ne sont pas forcément nécessaires, et qu'on peut surement s'en passer. De nombreux débutants en C se disent cela, et c'est une des raisons qui fait que certains ne comprennent pas facilement les pointeurs. À ce stade du tutoriel, il sera difficile de vous montrer leur utilité, même si la suite du tutoriel vous prouvera le contraire.

Néanmoins, cet exercice va vous montrer que les pointeurs peuvent parfois être utiles. En effet, nos pointeurs sont utiles en argument des fonctions. C'est ainsi : dans une fonction, il est impossible de modifier les arguments : on ne fait que manipuler une copie de ceux-ci ! Si jamais notre fonction doit manipuler un ou plusieurs des arguments pour le modifier, on ne peut pas le faire avec une fonction. Du moins, pas sans nos pointeurs.

Énoncés

Pour mieux comprendre, on va vous donner un exemple pratique. Vous aller devoir programmer une fonction nommé swap, qui va échanger le contenu de deux variables. Cette fonction ne semble pas bien méchante, mais sachez qu'elle est assez utilisée, mine de rien. Par exemple, si vous voulez trier un gros paquet de données, vous aurez absolument besoin de cette fonction. Mais je n'en dit pas plus. Quoiqu'il en soit, on va supposer que dans notre exemple, nos deux variables sont des int.

Bonne chance !

Correction

Celle-ci se trouve sur à la même adresse que d'habitude.


Structures

Dans le chapitre précédent, nous avions dit que les pointeurs étaient utiles pour manipuler des données complexes. Les structures sont les premières que nous allons étudier. Qu'est ce qu'une structure ? Il s'agit d'un regroupement de variables différentes sous le même nom. En gros, une structure est une grosse boite qui regroupe plein de données différentes.

Plein de parallèles avec le monde réel sont possibles. Prenez un salarié par exemple : il a un sexe, un âge, un salaire, un véhicule de fonction ou non, etc. Au lieu de créer une variable pour chaque donnée, on les regroupe toutes dans une sorte de "grosse variable" qui contient toutes les autres, la structure. Si cette illustration vous parait abstraite, lisez la suite qui montre une utilisation concrète en C.

  • Déclaration, définition et initialisation
  • Utilisation et pointeurs
  • Un peu de mémoire
  • Compléments

Déclaration, définition et initialisation

Pour gérer ces structures, on va devoir apprendre à en définir le contenu, puis à déclarer des variables de type structure et les initialiser. Commençons par la définition.

Définition

Par définir une structure, nous ne voulons pas dire créer une variable qui soit une structure. Cela sera pour plus tard. Avant de créer des variables structurées, nous devons indiquer à notre compilateur comment est organisée la structure que l'on veut créer. Cela veut simplement dire quels sont les types des variables qu'on rassemble, comment est organisée la structure, etc.

Le squelette de toute structure est le suivant :

1
2
3
4

struct Identificateur
{
    /* les variables à déclarer */
};

Décortiquons le tout. Le mot-clef struct est important, il permet d'indiquer au compilateur que l'on déclare une structure. Notez également que le point-virgule à la fin est obligatoire, sinon vous aurez des erreurs à la compilation. Les objets que l'on défini dans le bloc de code vu au-dessus sont quant à eux appelés les champs ou les membres de la structure. Je suppose que vous vous demandez s'il y a une limite. Je laisse la norme vous répondre :

The implementation shall be able to translate and execute at least one program that contains at least one instance of every one of the following limits : 127 members in a single structure or union -- C89 - 2.2.4.1 Translation limits

Ne vous inquiétez pas de cette limite, vous n'aurez sûrement jamais à déclarer une structure regroupant 127 variables ou plus.

Ensuite, il faut savoir qu'on initialise aucun membre à l'intérieur même de la structure. C'est interdit par le C. En effet, quand nous définissons une structure, aucune variable n'est en jeu : le compilateur ne réserve aucune place en mémoire. Il est donc insensé d'initialiser des variables qui n'existent pas en mémoire. Mais rassurez-vous, nous verrons plusieurs méthodes dans la suite du chapitre pour initialiser nos membres.

Enfin, sachez que l'on peut définir une structure tant dans un fichier d'en-tête que dans un fichier source. Cela dépend en fait du contexte : est-ce que la structure est interne à un fichier parce qu'elle a un usage très particulier, ou bien veut-on la partager de manière modulaire (dans ce cas, on la met dans un fichier d'en-tête) ? C'est à vous de voir.

Exemple

Pour donner un exemple de définition, voici celle d'une structure Personne qui stocke des informations administratives sur quelqu'un :

1
2
3
4
5
6
7
8

struct Personne
{
    double poids ;
    double taille ;
    unsigned int nombre_enfants ;
    unsigned int age;
    double salaire;
};

Déclaration

Ensuite, pour déclarer dans notre code un objet de type structure, il suffit de suivre ce schéma :

1

struct NomDeLaStructure identificateur;

La méthode est la même que pour une variable d'un type de base, si ce n'est que le mot-clef struct est obligatoire. Avec notre exemple de la structure Personne, cela donnerait ça :

1
2
3
4
5

int main(void)
{
    struct Personne ma_structure;
    return 0;
}

Initialisation

Comme je l'ai dit plus haut, il est interdit d'initialiser les champs d'une structures dans le bloc de code vu plus haut. Conséquence : on ne peut pas initialiser ces structures de cette manière et donc il est possible que l'on se retrouve avec des valeurs fantaisistes.. Heureusement, il existe une technique pour initialiser dès la déclaration :

1

struct NomDeLaStructure identificateur = {valeurs que l'on veut donner aux membres};

Il faut bien sur qu'il y ait autant de valeurs que de membres, et qu'elles soient déclarées dans le même ordre que les membres. Un petit exemple pour mieux visualiser ? C'est d'accord.

1

struct Personne ma_structure = {60.2, 175.0, 0, 24, 25000};

Bien entendu, on peut aussi initialiser chacun des membres à la main si l'on veut. Mais pour le moment, on ne sait pas comment accéder à un membre de notre structure, donc laissons cela pour plus tard.

typedef

Peut-être certains ce sont déjà posés la question : n'y a t-il pas un moyen d'enlever le mot-clef struct lors de la déclaration ? Eh bien la réponse est oui ! il existe un moyen : typedef. Ce mot-clef sert à définir des synonymes ou alias de types existants. C'est assez utile pour simplifier des déclarations complexes ou longues. Le modèle est le suivant :

1
2
3
4
5

typedef struct Identificateur Alias;
struct Identificateur
{
    /* champs de la structure */
};

Désormais, écrire struct Identificateur ou Alias revient au même pour le compilateur. A noter qu'il existe une autre possibilité, un peu plus rapide à écrire :

1
2
3
4

typedef struct
{
    /* champs de la structure */
} Identificateur;

Avec cette forme, écrire struct Identificateur ou Identificateur est identique. Le choix de l'une ou l'autre manière n'est pas vraiment important, c'est à vous de choisir celle que vous préférez. Ce tutoriel utilisera quand à lui la seconde forme. Il est aussi important de savoir que certains programmeurs, par soucis de clarté, n'utilisent jamais de typedef pour bien montrer qu'ils utilisent des structures.

Utilisation et pointeurs

Accès à un membre

Pour l'instant, on sait définir une structure, la déclarer, l'initialiser mais on ne sait toujours pas comment accéder aux membres qui la composent. La méthode est très simple, il suffit de faire comme ça :

1

Identificateur.membre;

Pour information, si vous utilisez un IDE, lorsque vous taperez le point, il se peut qu'on vous propose une liste des champs de votre structure : c'est l'auto-complétion.

Modification d'un membre

Il est possible de les modifier les membres d'une structure. Pour cela, on procède ainsi :

1

structure.membre = valeur_à_placer_dans_le_membre;

En gros, le membre subit une affectation comme une variable classique.

Pointeurs sur structures

Comme certains d'entre vous s'en sont peut-être déjà douté, il est possible de déclarer des pointeurs de structures. Le fonctionnement est exactement le même que pour les pointeurs sur des variables "classiques". Voici le code précédent qui déclare cette fois un pointeur de structure :

1

struct X * ptr;

Quel est l'intérêt ? Nous l'avons déjà dit dans le chapitre sur les pointeurs : tout argument d'une fonction est recopié, et c'est cette copie qui est utilisée dans la fonction. Vouloir éviter cette recopie peut se justifier dans deux cas : soit on a absolument besoin de manipuler l'original, soit on veut éviter des copies inutiles. Comme justification pour le second cas, il faut savoir qu'une recopie de la structure est une opération qui peut être lourde si la structure est grosse. En utilisant des pointeurs, on accède non plus à une copie mais à la structure elle-même, ce qui est plus rapide et efficace dans une grande majorité de cas.

Accès via un pointeur

Bon, on sait déclarer des pointeurs sur structures pour passer celles-ci dans nos fonctions. Seulement, accéder à un membre d'une structure via l'identificateur d'une structure, et passer par un pointeur sur structure, ce n'est pas la même chose. Prenons un exemple : je dispose d'une structure qui contient des informations médicales sur une personne. La structure est déclarée comme ceci :

1
2
3
4
5
6

typedef struct
{
    double poids; /* en kilo-grammes */
    unsigned int taille; /* en centimètres */
    unsigned int age;
} Personne;

Supposons que je veuille passer cette structure à une fonction chargée de calculer l'IMC de la personne. L'IMC, c'est l'Indice de Masse Corporelle : c'est un indicateur inventé par des statisticiens pour évaluer les risques de mortalité d'une personne en fonction de son poids et de sa taille. Il sert à évaluer si le poids d'une personne est à surveiller ou pas, et est utilisé par certains médecins, combiné avec d'autres analyses. Cet IMC se calcule assez simplement : il suffit d'élever la taille au carré, et de diviser par le poids.

On va supposer que je passe cette structure à ma fonction via un pointeur (on pourrait éventuellement faire autrement, mais passons). L'accès aux membres ne pourra pas se faire directement, en utilisant la syntaxe vue plus haut. Ainsi, une fonction comme ceci n'est pas valable :

1
2
3
4
5
6
7

double IMC (Personne * personne)
{
    taille = personne.taille;
    poids = personne.poids;

    return (taille * taille) / poids;
}

La raison est simple : dans ce code, personne est un pointeur, pas une structure. Pour accéder à un membre, je dois d'abord déréférencer le pointeur pour accéder à ma structure, et ensuite accorder au membre. Soit, faisons-le. Naïvement, on pourrait croire qu'il suffit de faire ceci :

1
2
3
4
5
6
7

double IMC (Personne * personne)
{
    taille = *personne.taille;
    poids = *personne.poids;

    return (taille * taille) / poids;
}

Mais là encore, vous vous trompez. En fait, il y a un petit piège qui vient des différentes priorités du point et de l'opérateur * (regardez ici pour en apprendre plus). Pour éviter tout problème, vous devez placer des parenthèses autour du * et de l’identificateur. Voici ce que cela donne dans l'exemple :

1
2
3
4
5
6
7

double IMC (Personne* personne)
{
    taille = (*personne).taille;
    poids = (*personne).poids;

    return (taille * taille) / poids;
}

Syntaxe alternative

Vu que faire ce genre de manipulations est quelque chose de courant, les concepteurs du C ont ajoutés un raccourci qui permet d'aller plus vite. Ainsi, la syntaxe (*structure).membre peut s'écrire aussi structure->membre. Sur l'exemple du haut, cela donnerait :

1
2
3
4
5
6
7

double IMC (Personne * personne)
{
    taille = personne->taille;
    poids = personne->poids;

    return (taille * taille) / poids;
}

Retenez bien cette syntaxe, vous l'utiliserez certainement assez souvent. En fait, c'est même celle qu'utilisent tous les programmeurs.

Exercice

Nous avons vu dans la partie précédente une syntaxe pour initialiser les structures à leur déclaration. Cependant, ce n'est pas toujours très pratique, notamment quand il y a de nombreux champs à préciser. Une meilleure solution serait de faire une fonction qui prendrait notre structure en paramètre et qui initialiserait tous ces membres. Pour vous entrainer, essayez de faire cette fonction pour la structure Personne.

Pour information, si vous connaissez des langages orientés objets, vous reconnaitrez sans doute cette fonction qui n'est d'autre qu'une sorte de constructeur.

[secret]{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

/* Je n'ai pas mis de typedef pour
vous montrer la différence au niveau
de la fonction */

struct Personne
{
    double poids;
    double taille;
    unsigned int nombre_enfants;
    unsigned int age;
    double salaire;
};

void Personne_Init(struct Personne * employe)
{
    employe->poids = 55;
    employe->taille = 175;
    employe->nombre_enfants = 2;
    employe->age = 30;
    employe->salaire = 25000;
}

int main(void)
{
    struct Personne employe;
    Personne_Init(&employe);
    return 0;
}

J'ai fixé des valeurs dans mon code par choix, mais rien ne vous empêche de les changer. C'est à vous d'adapter le code à vos besoins. }

Vous pouvez aussi vous entrainer en codant une fonction affichant les données, une modifiant certains champs voire tous, etc. Votre imagination est la seule limite.

Un peu de mémoire

On sait maintenant utiliser nos structures. C'est bien, et pour être franc, on n'a pas vraiment besoin d'en savoir beaucoup plus pour utiliser celles-ci de manière basique. Mais un peu de culture générale ne fait pas de mal.

Représentation en mémoire

Savez-vous comment sont représentées nos structures en mémoire ? Rien de plus simple : en théorie, les variables d'une structure sont placées les unes après les autres en mémoire. J'ai bien dit en théorie, mais laissons cela pour plus tard. Par exemple, prenons une structure bidon.

1
2
3
4
5
6

struct Exemple
{
    double flottant;
    char lettre;
    unsigned int entier;
};

On va supposer qu'un double prend 8 octets, qu'un char en prend 1, et qu'un unsigned int en prend 4. Voici ce que donnerait cette structure en mémoire :

Du moins, c'est de la théorie, comme nous allons bientôt le voir.

sizeof

Maintenant, supposons que vous vouliez connaitre la taille que votre structure prend en mémoire. Pour le savoir, vous pouvez utiliser l'opérateur sizeof. Cet opérateur donne la taille en bytes d'un type et s'utilise comme ceci : sizeof (char), sizeof (int), etc. Bref, il suffit de placer sizeof et de préciser le type dont on veut connaitre la taille entre parenthèses juste après (pour être plus exact, les parenthèse sont obligatoires dans le cas d'un type mais pas d'un identificateur, mais faisons comme si elles étaient tout le temps obligatoires, plus simple à retenir).

Petite remarque : le résultat de l'opérateur sizeof est de type size_t et non de type int ou unsigned int comme on serait tenter de le penser de prime abord. La raison est très simple : cela est dû aux bornes de ces types. En effet, pour connaitre la taille d'un type simple comme int ou char, les bornes d'un unsigned int ou d'un int ne pose pas de problèmes. Mais sizeof peut aussi servir pour récupérer autre chose comme la taille de données plus élaborées. Et sur certains ordinateurs, on peut parfaitement avoir des données dont la taille dépasse les bornes d'un unsigned int ou d'un int. Aussi, pour résoudre ce problème, le type size_t a été crée et permet de contenir la taille de n'importe quelle donnée.

À ce sujet, je vous propose un petit exercice : essayer d'afficher la taille de tous les types que vous connaissez. En C89, il n'existe pas d'indicateur de conversion spécifique à size_t. Il vous faudra donc utiliser celui prévu pour le type unsigned long (pour rappel, "%lu") et convertir le résultat de l'opérateur sizeof.

[secret]{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <stdio.h>

int main(void)
{
    printf("Taille d'un char = %lu\n", (unsigned long) sizeof(char));
    printf("Taille d'un short = %lu\n", (unsigned long) sizeof(short));
    printf("Taille d'un int = %lu\n", (unsigned long) sizeof(int));
    printf("Taille d'un long = %lu\n", (unsigned long) sizeof(long));
    printf("Taille d'un float = %lu\n", (unsigned long) sizeof(float));
    printf("Taille d'un double = %lu\n", (unsigned long) sizeof(double));
    printf("Taille d'un long double = %lu\n", (unsigned long) sizeof(long double));
    return 0;
}

Que l'on se mette d'accord : ceci est le comportement rigoureux que l'on vous enseigne. Sans le cast, le comportement est indéterminé. Néanmoins, il reste assez lourd, et sachez que si vous n'êtes pas sur un ordinateur trop exotique (un PC par exemple), vous pouvez vous passer de cette technique. }

Ceci étant fait pour des types simples, vous pouvez aussi le faire pour des structures. Par exemple, reprenons notre structure Exemple :

1
2
3
4
5
6

struct Exemple
{
    double flottant;
    char lettre;
    unsigned int entier;
};

Avec ce qu'on a dit plus haut, notre structure devrait prendre 8 octets pour un double, un octet pour le char, et 4 pour le unsigned int, ce qui donne un total de 13 octets. Essayons ce que cela donne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

typedef struct Exemple
{
    double flottant;
    char lettre;
    unsigned int entier;
} Exemple;

int main (void)
{
    printf ("%d", sizeof(Exemple));
    return 0;
}

Et ce code affiche :

1

16

...

Hé hé, je dois avouer que je suis vraiment fier de mon coup. Et oui, vous êtes encore tombés dans un piège dont vous ne pouviez pas soupçonner l’existence. Je suis machiavélique !

Alignement en mémoire

J'avoue que je vous dois des explications. Mais tout d'abord, sachez que ce 16 ne sort pas de n'importe où et qu'il est parfaitement normal. Mais autant prévenir : l'explication va paraitre assez déroutante. On va en effet aller regarder ce qui se passe au plus profond de la mémoire !

Mots

Lorsque notre processeur va vouloir manipuler un champ de notre structure, il va d'abord commencer par la lire depuis la mémoire. Cette donnée sera alors transmise au processeur. Cette transmission se fait par un intermédiaire. Cet intermédiaire, c'est un ensemble de fils qui relient la mémoire et le processeur, et par lequel les données vont s’échanger entre processeur et mémoire. On l'appelle le bus de donnée.

Ce bus de donnée permet souvent de charger plusieurs octets depuis la mémoire. Le processeur peut ainsi charger 2, 4 ou 8 octets d'un seul coup (parfois plus). On dit que le processeur accède un mot en mémoire. Ce mot n'est rien d'autre qu'une donnée qui a la même taille que le bus de donnée. Suivant le processeur, il existe parfois des restrictions sur la place de chacun de ces mots en mémoire.

Alignement

Certains processeurs ou certaines mémoires imposent des restrictions assez drastiques dans la façon de gérer ces mots. Certains processeurs (ou certaines mémoires) regroupent les cases mémoires en "blocs" de la taille d'un mot : ceux-ci utilise un certain alignement mémoire. On peut voir chacun de ces blocs comme une "case mémoire" fictive un peu plus grosse que les cases mémoires réelles et considérer que chacun de ces blocs possède une adresse. L'adresse d'un de ces groupes est l'adresse de l'octet de poids faible. Les adresses des octets situé dans le groupe (c'est à dire autre que l'octet de poids faible) sont inutilisables : on ne peut adresser qu'un groupe, via son octet de poids faible, et charger l'intégralité de ce mot sur le bus, mais pas accéder à un octet en particulier.

L'adressage de la mémoire est donc moins "fin" : on travaille sur des blocs de plusieurs bits, plutôt que sur des petits paquets. Dans la réalité, ces blocs ont une taille égale à une puissance de deux : cela permet de faire quelques bidouilles sur le bus d'adresse pour économiser des fils et de simplifier le design de la mémoire. Des mots de 8 octets (64 bits) ne sont pas rares sur nos ordinateurs actuels.

Accès mémoires non-alignés

Bon, maintenant imaginons un cas particulier : je dispose d'un processeur utilisant des mots de 4 octets. Je dispose aussi d'un programme qui doit manipuler un caractère stocké sur 1 octet, un entier de 4 octets, et une donnée de 2 octets. Mais un problème se pose : le programme qui manipule ces données a été programmé par quelqu'un qui n'était pas au courant de ces histoire d'alignement, et il a répartit mes données dans la structure comme ceci :

1
2
3
4
5
6

typedef struct Exemple
{
    char lettre;
    unsigned int entier_long;
    short entier_court ;
} Exemple;

Supposons que cet entier soit stocké à une adresse non-multiple de 4. Par exemple :

Adresse | Octet 4 | Octet 3 | Octet 2 | Octet 1
A | Caractère | Entier | Entier | Entier
A + 4 | Entier | Donnée | Donnée | -
A + 8 | - | - | - | -

Pour charger mon caractère dans un registre, pas de problèmes : celui-ci tient dans un mot. Il me suffit alors de charger mon mot dans un registre en utilisant une instruction de mon processeur qui charge un octet. Pour ma donnée de 2 octets, pas de problèmes non plus, vu que celui-ci est dans un mot. Mais si je demande à mon processeur de charger mon entier, ça ne passe pas ! Mon entier est en effet stocké sur deux mots différents, et on ne peut le charger en une seule fois : mon entier n'est pas aligné en mémoire. Dans ce cas, il peut se passer des tas de choses suivant le processeur qu'on utilise. Sur certains processeurs, la donnée est chargée en deux fois : c'est légèrement plus lent que la charger en une seule fois, mais ça passe. Mais sur d'autres processeurs, la situation devient nettement plus grave : le programme responsable de cet accès mémoire en dehors des clous se fait sauvagement planter le faciès sans la moindre sommation.

Padding

Pour éviter ce genre de choses, les compilateurs utilisés pour des langages de haut niveau préfèrent rajouter des données inutiles (on dit aussi du padding) de façon à ce que chaque donnée soit bien alignée sur le bon nombre d'octets. En reprenant notre exemple du dessus, et en notant le padding X, on obtiendrait ceci :

Adresse | Octet 4 | Octet 3 | Octet 2 | Octet 1
A | Caractère | X | X | X
A + 4 | Entier | Entier | Entier | Entier
A + 8 | Donnée | Donnée | X | X

Ce sont ces données de padding qui ont fait passer notre structures de 13 à 16 octets. Comme quoi, l'explication était au final très simple. Comme vous le voyez, ces données de padding prennent un peu plus de place, et de la mémoire est gâchée inutilement.

Économies

Mais dans certains cas, on peut éviter de gaspiller de la mémoire inutilement en faisant attention à l'ordre de déclaration de nos variables. Par exemple, si on reprend l'exemple du dessus, on peut gagner 4 octets facilement. Il suffit de déclarer la structure comme ceci :

1
2
3
4
5
6

typedef struct Exemple
{
    unsigned int entier_long;
    short entier_court ;
    char lettre;
} Exemple;

Si on regarde bien, cette structure donnerait ceci en mémoire :

Adresse | Octet 4 | Octet 3 | Octet 2 | Octet 1
A | Entier | Entier | Entier | Entier
A + 4 | Donnée | Donnée | Caractère | X

Ce qui prend seulement 8 octets au lieu de 12. Certains octets de padding ont étés éliminées. Moralité : programmeurs, si vous voulez économiser de la mémoire, faites gaffe à bien gérer l'alignement en mémoire ! Essayez toujours de déclarer vos variables de façon à remplir un mot intégralement ou le plus possible. Renseignez-vous sur le padding, et essayez de savoir quelle est la taille de vos données avec ce cher sizeof.

Compléments

Structures contenant des structures

Il est possible qu'une structure contienne des variables de type structure. Rien n’empêche d'imbriquer des structures dans d'autres. Pour vous en convaincre, sachez que l'exemple suivant est tout à fait légal en C.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

struct Point2D
{
    int x;
    int y;
};

struct Point3D
{
    struct Point2D point2D;
    int z;
};

Il y a deux conditions, la première étant bien sûr que la structure inclue soit définie avant celle l'incluant ; la deuxième est qu'une structure ne peut pas contenir des structures de son propre type : il semble évident qu'une structure de type X ne puisse pas contenir des structures de type X.

Déclarations anticipées et pointeurs de structures

La norme autorise le fait que l'on puisse déclarer une structure sans en préciser les champs si le compilateur n'a pas besoin de connaître la description de la structure (comme dans le cas des pointeurs sur structures). Ainsi, une déclaration anticipée de notre structure d'exemple de ce chapitre serait struct Personne; tout simplement. Bien entendu, il faut que dans le code il existe au moins une description complète des champs de la structure.

Quel est l'intérêt des déclarations anticipées ? Eh bien il y en a deux. Le premier est en cas d'interdépendance entre structures. Imaginez deux structures A et B : la première contient un pointeur sur une structure de type B, et vice-versa. Illustration avec ce code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

struct A;

struct B
{
    struct A * var_a;
};

struct A
{
    struct B * var_b;
};

Le deuxième intérêt des déclarations anticipées est si une structure contient des pointeurs sur des structures de son propre type. Ça parait obscur ? Il s'agit simplement de ce cas là :

1
2
3
4
5
6

struct A;

struct A
{
    struct A * ptr_a;
};

Ce code constitue une exception à la deuxième condition que nous avions énoncé dans la sous-partie précédente : il est possible de déclarer un pointeur (mais seulement un pointeur) sur une structure du même type que la structure que nous sommes en train de créer. Nous aurons l'occasion d'utiliser des structures de ce genre dans les chapitres sur les listes chainées.

On va quand même faire un test : supprimer la déclaration anticipée du code précédent et constatez ... qu'il marche ! Encore une exception ? Eh bien il faut savoir que la portée d'une structure (revoyez le chapitre Découper son projet si vous avez oublié ce que c'est) démarre à l'accolade ouvrante du début et non au point-virgule à la fin. Ainsi, dès que je fais ceci :

1
2

struct Identificateur
{

Je peux créer des pointeurs sur des structures de type Identificateur sans avoir à utiliser une déclaration anticipée. Tout ça est compliqué ? Alors n'hésitez pas à reprendre cette partie à tête reposée, car même si elle est théorique elle permettra de mieux comprendre la partie 5.


Les tableaux

Continuons notre découvertes des données complexes en abordant cette fois la notion de tableaux. Comme le nom l'indique, un tableau est une regroupement de données, tout comme les structures. Mais alors, y'a t-il des différences et si oui quelles sont-elles ? La réponse se trouve à la suite, mais sachez que comme les structures, les tableaux sont très utilisés en C.

  • C'est quoi un tableau ?
  • Déclaration et initialisation
  • Accès aux éléments
  • Tableaux et fonctions
  • Exercices

C'est quoi un tableau ?

En C, un tableau est une suite contigüe de données de même type. Cela implique deux choses que nous allons expliciter.

· Une suite contigüe de données. Cela veut dire que les données d'un tableau sont rangées les unes à la suite des autres en mémoire : on ne laisse pas le moindre vide. Les adresses sont par conséquent également consécutives. En clair, tout se passe comme si on avait rassemblé plusieurs variables de même type les unes à côté des autres. Un tableau est donc un gros bloc de mémoire qui commence à une adresse déterminée (celle de son premier élément) et qui contient un nombre fini d’éléments.

· Tous les éléments d'un tableau sont de même type. On ne peut donc pas se retrouver avec un int au beau milieu d'un tableau de char, par exemple. Du fait que les éléments sont de même type, ces derniers ont également tous la même taille et il est dès lors possible de déterminé la taille occupée en mémoire par un tableau : elle est égale au produit du nombre d’éléments, sa longueur, par la taille d'un élément.

Leur utilité

Le principal intérêt des tableaux est de rassembler plusieurs variables de même type ensemble pour les regrouper dans un seul objet.

Par exemple, supposons que vous vouliez créer un petit programme pour calculer la moyenne de votre année scolaire. Vous aurez besoin de stocker toutes vos notes quelque part dans votre ordinateur. Bien entendu, vous pourriez utiliser autant de variables que vous avez de notes, mais cela serait très long à écrire. À la place, vous pouvez créer un tableau qui contiendra toutes vos notes, chaque élément de votre tableau étant une note (que vous pourrez stocker dans un nombre flottant ou un entier, par exemple), et manipuler directement ce tableau.

Déclaration et initialisation

Maintenant que nous avons vu ce qu'était un tableau, nous allons voir comment déclarer une variable de type tableau et comment l'utiliser.

Déclaration

La déclaration d'un tableau nécessite trois informations :

  • la première est le type des éléments du tableau (rappelez vous qu'un tableau est une suite de données de même type) ;
  • la seconde est le nom du tableau, son identificateur ;
  • la dernière est la longueur du tableau (son nombre d'éléments). Cette dernière doit obligatoirement être une constante entière (il est cependant possible d'utiliser une macroconstante, comme nous le verrons).
1

type identificateur[longueur];

Comme vous le voyez, la syntaxe de la déclaration d'un tableau est similaire à celle d'une variable, la seule différence étant qu'il faut préciser le nombre d’éléments entre crochets à la suite de l'identificateur du tableau.

Exemples

Ainsi, si je souhaite par exemple déclarer un tableau contenant 20 int, je dois procéder comme suit :

1

int tableau[20];

C'est aussi simple que ça. Entrainons-nous avec quelques exemples simples pour voir si vous avez bien compris. Essayez de déclarer un tableau :

  • de 15 char ;
  • de 25 float ;
  • de 4 unsigned int ;
  • de 10 short ;
  • de 5 struct Personne (celle vue dans le chapitre précédent) ;
  • de 7 pointeurs sur int ;

Et voilà la correction. Comme vous pouvez le voir, il est possible de déclarer un tableau de n'importe quel type, aussi complexe soit-il.

Macroconstante

Concernant la taille, il est également possible de la définir à l'aide d'une macroconstante, comme ceci :

1
2
3

#define N 5

int tab[N];

En effet, après le pasage du préprocesseur, la macroconstante sera remplacée par sa définition (à savoir la constante entière 5 dans notre cas), ce qui est parfaitement valide.

Initialisation

Lorsque l'on déclare un tableau, il se passe la même chose que pour les variables : si celui-ci n'est pas déclaré avec le mot-clé static, ses éléments ont une valeur indéterminée. L'ordinateur a juste réquisitionné un bloc de mémoire pour stocker ce tableau et c'est tout, il n'a pas modifié son contenu.

Comme pour les variables, il est possible d’initialiser un tableau, ce qui n'est rien de moins qu'initialiser tout ou partie de ses éléments. Pour ce faire, il y a deux manières de procéder, mais dans les deux cas, il est nécessaire de préciser les valeurs des éléments du tableau une par une. Ces valeurs seront séparées par une virgule, et l'ensemble de ces valeurs est entouré d'accolades. Le tout est affecté au tableau lors de sa déclaration.

Initialisation avec longueur explicite

Le premier type d'initialisation consiste à initialiser un tableau dont la longueur est précisée entre crochets. Dans ce cas, il est indispensable de respecter cette longueur pour éviter des erreurs (pour un tableau de longueur $x$, on doit initialiser au plus $x$ éléments).

1

type identificateur[longueur] = {valeurs};

Prenons l'exemple d'un tableau de 3 entiers, dont on veut initialiser le premier élément à 5, le second à -48 et le dernier à 972.

1

int tableau[3] = {5, -48, 972};

Dans cet exemple, on a un tableau de 3 int, on est donc obligé de respecter cette longueur, et on ne peut initialiser que 3 éléments au maximum. Mettez-en plus et vous risquez d'avoir des problèmes.

Petite remarque : il est possible de n'initialiser que certains éléments d'un tableau déclaré de cette façon. Il suffit de ne pas préciser les valeurs des autres éléments.

1

int tableau[5] = {2, 3};

Dans cet exemple, je définis un tableau de 5 int, mais je n'initialise que les deux premiers éléments. Que deviennent dès lors les autres ? Ils sont automatiquement mis à zéro. Du coup, pour initialiser tous les éléments d'un tableau à zéro, il suffit d'écrire :

1

type identificateur[longueur] = {0};

Initialisation avec longueur implicite

Le second type d'initialisation consiste à initialiser un tableau dont la longueur n'est pas fixée.

1

type identificateur[] = {valeurs};

Dans ce cas, la longueur est précisée de façon implicite : c'est le nombre d'éléments entre accolades qui la déterminera. Prenons l'exemple ci-dessous :

1

int tableau[] = {7, 8, 9, -41, 213, 945};

Dans ce dernier, 6 éléments sont initialisés, on obtient donc au final un tableau de 6 int

Accès aux éléments

Si on sait maintenant déclarer et initialiser des tableaux, il faut encore que ceux-ci servent à quelque chose. Et pour cela, on doit pouvoir modifier ou récupérer les éléments de celui-ci. Pour cela, chaque élément d'un tableau est identifié par sa place dans le tableau. On peut choisir d’accéder au premier élément d'un tableau, au second, au troisième, ..., au n-ième élément, etc. Pour cela, chacun de ces éléments se voit attribuer un indice (un nombre entier) qui détermine sa place dans le tableau.

Mais il y a une petite subtilité : ces indices commencent toujours à 0. Le premier élément est celui d'indice 0, le second a pour indice 1, le troisième est à l'indice 2, etc. Pour un tableau de $x$ éléments, les indices vont donc de 0 à $x - 1$. On peut notamment se demander pourquoi le premier élément d'un tableau se voit attribuer l'indice zéro. Après tout, il aurait été plus logique de faire commencer nos indices à 1. D'ailleurs, c'est le cas dans d'autres langages de programmation : le premier indice d'un tableau n'est pas le zéro, mais le 1. Et certains langages vont encore plus loin, mais passons.

Calcul d'adresse

Alors pourquoi ce choix contre-intuitif ? Cela vient de la façon dont sont stockés les tableaux en mémoire. En fait, pour accéder à un élément, vous devrez connaitre l'adresse de celui-ci. Heureusement, déterminer l'adresse d'un élément d'un tableau se fait assez simplement. En effet, puisque tous les éléments se suivent en mémoire, leurs adresses peuvent toutes se calculer à l'aide d'un indice et de l'adresse du début du tableau (celle de son premier élément).

Manipuler les éléments d'un tableau se fait avec des pointeurs. Pour accéder à un élément d'un tableau, vous devrez connaitre l'adresse de celui-ci, et donc avoir un pointeur sur cet élément. Déterminer l'adresse d'un élément dans un tableau se fait assez simplement: quelques calculs suffisent. Eh oui, vous avez bien lu : notre adresse peut être calculée. Pour calculer l'adresse d'un élément, vous avez besoin de deux choses : son indice, et l'adresse de début du tableau. Pour calculer cette adresse, on utilise le fait que les éléments d'un tableau ont une taille fixe et sont rangés dans des adresses mémoires consécutives.

Prenons un exemple : un tableau d'entiers, prenant chacun 4 octets. Le premier élément d'indice zéro est placé à l'adresse $A$ : c'est l'adresse à laquelle commence le tableau en mémoire. Le second élément est placé 4 octets après (vu que le premier élément prend 4 octets) : son adresse est donc $A+4$. Le troisième élément est placé 4 octets après le second élément, ce qui donne l'adresse $(A+4) + 4$.

Si vous continuez ce petit jeu pour quelques valeurs, on obtiendrait quelque chose dans le genre :

Indice i | Adresse de l'élèment
0 | A
1 | A+4
2 | A+8
3 | A+12
4 | A+16
5 | A+20
... | ...

Vous remarquerez surement que l'on ajoute toujours un multiple de quatre (puisque dans cet exemple la taille d'un int est de quatre octets) et que l'on peut dès lors reformuler la table ci-dessus comme suit :

Indice i | Adresse de l'élèment
0 | A + (0 * 4)
1 | A + (1 * 4)
2 | A + (2 * 4)
3 | A + (3 * 4)
4 | A + (4 * 4)
5 | A + (5 * 4)

On peut donc formaliser cette remarque mathématiquement en posant $T$ la taille d'un élément du tableau, $i$ l'indice de cet élément, et $A$ l'adresse de début du tableau (l'adresse du premier élément) : l'adresse de l’élément d'indice $i$ vaut toujours $A + T \times i$.

Avec cette formule, on voit bien que l'indice du premier élément ne peut pas être autre chose que 0. Le premier élément a la même adresse que le début du tableau : c'est-à-dire A. On est obligé d'avoir i = 0 pour que cette formule convienne. Après, rien n’empêche de faire quelques bidouilles arithmétiques sur l'indice pour que le premier élément ait un indice différent de 0, mais le calcul de l'adresse sera alors plus compliqué, plus long à effectuer. Et dans un langage comme le C, conçu pour être performant, c'est niet !

Quoi qu'il en soit, pour effectuer ce calcul d'adresse, nous avons besoin de trois éléments :

  • la taille du type de chaque élément ;
  • l'adresse de début du tableau ;
  • l'indice.

Adresse du premier élément

Pour effectuer ce calcul d'adresse, il nous manque encore l'adresse du début de notre tableau. Cette adresse n'est rien d'autre que l'adresse de son premier élément. Reste à savoir comment le C nous permet d'obtenir cette adresse.

La solution est sous vos yeux : regardez l'identificateur de votre tableau, c'est ça votre adresse. Hé oui, c'est choquant, et pourtant c'est la vérité ! En effet, dans une expression, l’identificateur d'un tableau se comporte comme un pointeur constant sur le début du tableau. Du moins, ce n'est valable qu'en dehors des déclarations et à l'exception de deux cas que nous allons voir tout de suite.

Remarquez que puisque l'identificateur d'un tableau se comporte comme un pointeur constant sur son premier élément, il est dès lors impossible de lui assigner quoi que ce soit.

1
2
3
4

int tab1[3] = {-78, 4688, 5};
int tab2[3];

tab2 = tab1; /* interdit */

La première exception est l'application de l'opérateur & à un identificateur de type tableau. En effet, les expressions &nom_du_tableau et nom_du_tableau contiennent toutes les deux l'adresse du premier élément. La preuve : essayez ce petit code, pour vous en convaincre.

1
2
3
4
5
6
7
8
9

#include <stdio.h>

int main (void)
{
    int tab[5] = {5, 5, 5, 5, 5};

    printf ("%d", tab == &tab);
    return 0;
}

La seconde exception est l'utilisation de l'opérateur sizeof qui, lorsqu'il est appliqué à un identificateur de type tableau, donne bien la taille de tout le tableau et non la taille d'un pointeur comme on serait tenter de le penser. L'exemple ci-dessous illustre cette propriété.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

#include <stdio.h>

int main(void)
{
    int tab[10];

    printf("sizeof(int) = %lu\n", (unsigned long)sizeof(int));
    printf("sizeof(int *) = %lu\n", (unsigned long)sizeof(int *));
    printf("sizeof(tableau) = %lu\n", (unsigned long) sizeof(tab));
    return 0;
}

1
2
3

sizeof(int) = 4
sizeof(int *) = 4
sizeof(tableau) = 40

On observe que la troisième ligne affiche bien la taille du tableau en octets ($10 \times 4$ octets) et non celle d'un pointeur comme la deuxième ligne. Cette particularité est utile pour obtenir la longueur d'un tableau : il suffit de diviser la taille totale du tableau par la taille d'un élément.

1
2
3
4
5
6
7
8
9

#include <stdio.h>

int main(void)
{
    int tab[10];

    printf("nombre d'éléments = %lu\n", (unsigned long)(sizeof(tab) / sizeof(int)));
    return 0;
}

Arithmétique des pointeurs

Ensuite, malins comme vous êtes, vous vous êtes dit que calculer l'adresse se faisait en utilisant directement la formule vue au-dessus. Prenons un exemple : vous voulez accéder à l’élément d'indice 7 d'un tableau de int nommé tab. Vous êtes donc tentés d'écrire quelque chose comme : tab + (7 * sizeof(int)).

Le seul problème, c'est que cela ne marchera pas ! Pourtant, vous n'avez rien fait de mal. Vous êtes juste tombé dans un piège : en C, les pointeurs sont typés. Un pointeur sur un int n'est pas vraiment la même chose qu'un pointeur sur un char. La différence tient dans le fait que le compilateur tient compte du type du pointeur pour faire les calculs. En C, si j'ai un pointeur sur un int nommé ptr, ++ptr ne pointe pas sur l'adresse suivante, mais sur l'adresse du int placé juste après. Et c'est la même chose pour les additions : ptr + 7 pointera sur l'adresse du 7ème int suivant ptr. En clair : dans notre calcul d'adresse, on n'a pas besoin de mentionner la taille prise par un élément. Le compilateur le fait pour vous. Pour accéder à un élément d'indice i dans un tableau nommé tab, il suffit donc de calculer son adresse en faisant (tab + i), et de déréférencer le tout. On obtient alors l'expression *(tab + i).

Formalisme tableaux

Calculer des adresses à la main de cette façon est tout de même assez lourd. Heureusement, le C fournit une syntaxe un peu plus simple afin d'accéder à un élément d'un tableau en fonction de son indice. Il ne vous est donc pas nécessaire de vous soucier de tout ce calcul d'adresse, le compilateur s'en chargera pour vous. Cette syntaxe est la suivante :

1

identificateur[indice];

Notez qu'il est également possible d'utiliser la syntaxe suivante qui est strictement équivalente :

1

*((identificateur) + (indice))

Cependant, comme vous le voyez, la syntaxe à base de [] est beaucoup plus lisible, et il n'y a aucune raison de s'en passer : elle est plus lisible, n'est pas plus lente (au contraire), elle est plus rapide à taper, etc. C'est ce qu'on appelle un sucre syntaxique, à savoir une petite fonctionnalité qui permet de rendre un langage plus agréable et plus facile à lire ou à écrire. Maintenant que vous connaissez cette syntaxe, sachez que c'est celle-ci qu'il est préférable d'utiliser pour accéder à un élément dans un tableau. Afin d'être tout à fait clair, voici un exemple affichant les valeurs respectives des différents éléments d'un tableau.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    int tab[4] = {10, 20, 30, 40};

    printf("%d\n", tab[0]);
    printf("%d\n", tab[1]);
    printf("%d\n", tab[2]);
    printf("%d\n", *((tab) + (3))); /* équivalent à tab[3] */
    return 0;
}

Le cas des structures

Les tableaux de structures s'utilisent en même temps comme un tableau et comme une structure. Si l'on prend un tableau de $n$ structures, alors on accède aux éléments comme ceci s'il s'agit d'un tableau de structures :

1

identificateur[indice].champs;

et comme ceci s'il s'agit d'un tableau de pointeurs de structures :

1

identificateur[indice]->champs;

Ce n'est pas très compliqué, mais mieux vaut clarifier pour ceux qui auraient des doutes.

Débordement de tableaux

Une des plus grandes erreurs en C est le débordement de tableaux à cause des indices. En effet, si vous tentez d'accéder à une case qui n'appartient pas à votre tableau, vous vous retrouvez face à un comportement indéterminé. C'est certainement le point le plus important du chapitre, mais aussi celui qui entraine le plus de problèmes : si par inadvertance, vous essayez d'accéder à un élément qui n'appartient pas au tableau sur lequel vous êtes en train de travailler, vous aurez de gros soucis.

Exemple

Regardez ce code par exemple, volontairement erroné :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

#include <stdio.h>

int main(void)
{
    int tableau[5] = {784, 5, 45, -12001, 8};
    int somme = 0;
    size_t i;

    for(i = 0; i <= 5; ++i)
    {
        somme += tableau[i];
    }

    printf("%d\n", somme);
    return 0;
}

Le problème de ce code se situe comme vous l'aurez compris au niveau de la boucle. En effet, le tableau contient 5 cases et le programme en parcours 6 (i <= 5 à la place de i < 5) ! Donc on tente d'accéder à une zone qui ne nous appartient pas. C'est une erreur assez courante et qu'on peut éviter simplement : il suffit de ne pas oublier que pour un tableau à $n$ éléments, les indices valides vont de 0 à $n-1$. Soyez donc prudent quand vous utilisez les boucles pour parcourir des tableaux.

Tableaux et fonctions

Dans ce qu'on a vu précédemment, on sait comment déclarer et initialiser des tableaux, et récupérer un élément avec un indice. Mais cela ne suffit pas pour savoir comment manipuler nos tableaux : on doit encore voir comment faire fonctionner ensemble tableaux et fonctions. Il existe en effet quelques petites singularités qu'ont les tableaux quand on cherche à les utiliser avec des fonctions. Il est parfois impossible de le retourner, et passer des tableaux en argument faire apparaitre certains comportements assez déroutants. Voyons un peu ce qu'il en est.

Passer un tableau en paramètre de fonction

Pour envoyer un tableau dans une fonction, il nous faut passer en argument le tableau, et éventuellement sa taille. Ce dernier paramètre est obligatoire dès lors que vous voulez faire une boucle : il faut bien transmettre la taille du tableau pour que la boucle ne dépasse pas le nombre de case du tableau.

Pour passer un tableau à une fonction, il suffit de la déclarer comme ceci :

1

type_fonction identificateur(type_tableau tableau[]);

Voici un exemple banal de fonction :

1

void fonction(int tableau[], size_t taille);

Pourquoi ne précise-t-on pas la taille du tableau entre crochets ? Car le compilateur n'en tient pas compte. Peu importe la taille entre crochets, le compilateur l'ignore. Si vous connaissez la taille et que le tableau ne change jamais, vous pouvez la mettre. Si vous n'êtes pas sûrs, dans le doute, ne mettez rien.

Une histoire de copie

Au fait : si vous vous souvenez de ce qu'il y a écrit plus haut, vous savez qu'en dehors de deux exceptions, un tableau est souvent converti en un pointeur sur son premier élément. Si vous passez un tableau en argument d'une fonction, ce sera le cas. Grâce à cette conversion, il est également possible de passer un tableau en argument d'une fonction comme ceci :

1

int fonction(int * tableau);

Cette forme est strictement équivalente à l'autre forme vue plus haut. Le choix de telle ou telle forme est personnel et ne change rien ; moi-même, j'utilise la première forme, que je trouve plus explicite. Quoi qu'il en soit, dans les deux cas on passe l'adresse du tableau à la fonction, ce qui fait qu'on travaille toujours sur l'original et non sur une simple copie comme c'est le cas avec les variables.

Retourner un tableau

De la même manière que pour les arguments, si vous retournez un tableau, votre fonction retournera au final l'adresse de son premier élément et donc un pointeur sur son premier élément. Cependant, garder à l'esprit que les variables définies au sein d'une fonction sont détruites à la fin de celle-ci et que, par conséquent, vous retourner dès lors l'adresse d'un tableau qui n'existe plus.

Exercices

Comme nous avons vu plein de nouveautés dans ce chapitre, il faut pratiquer pour bien retenir toutes ces notions. Je vous propose donc toute une série d'exercices pour manipuler les tableaux. À chaque fois, je vous donne l'énoncé, puis c'est à vous de coder. Vous pourrez alors comparer avec la correction. Sachez que mes codes ne sont pas les meilleurs, et si vous arrivez à faire l'exercice, c'est déjà très bien. Donc pas d'inquiétude si votre code est différent du mien.

Somme

Pour commencer, essayez de calculer la somme d'un tableau, c'est-à-dire la somme de tous ses éléments, et de retourner le résultat. Bon courage. Si vous avez fini, alors voici la correction.

Maximum et minimum

On peut aussi faire un peu plus complexe que simplement calculer la somme d'un tableau. Dans cet exercice, vous allez devoir créer une fonction qui donne le maximum et le minimum d'un tableau. Comme on veut récupérer deux valeurs, on ne peut pas utiliser de return. Un conseil : commencer par créer une fonction qui trouve le maximum (ou le minimum) d'un tableau et cherchez à l'améliorer pour qu'elle puisse gérer aussi le minimum. Mais attention : souvenez-vous qu'une fonction ne peut renvoyer qu'une seule valeur à la fois. Gérer maximum et minimum nécessitera une astuce.

Voici ma solution parmi tant d'autres. Comme vous le voyez, j'ai utilisé des pointeurs pour fournir le maximum et le minimum de mon tableau. Comme quoi, les pointeurs sont utiles.

Recherche d'un élément dans un tableau

Dans cet exercice-ci, vous allez devoir créer une fonction qui prend en entrée un nombre, un tableau de int et la longueur du tableau. Celle-ci teste la présence de ce nombre dans le tableau passé en entrée. La fonction doit renvoyer 0 si le nombre n'est pas dans le tableau, et 1 s’il y est présent.

À vos claviers ! Voici la correction. Pour information, on peut faire bien plus rapide si jamais le tableau passé en entrée de notre fonction est trié. On peut alors utiliser une recherche dichotomique.

Inverser les éléments d'un tableau

Cette fois-ci, on va vous demander quelque chose de plus compliqué. Vous allez devoir inverser les éléments d'un tableau : celui qui se trouve à la fin va se retrouver au début (et réciproquement), le second élément va se retrouver avant-dernier (et réciproquement), etc. Autre détail : vous n'avez pas le droit d'utiliser un autre tableau pour résoudre cet exercice. Le seul tableau que vous devez manipuler dans cet exercice est celui qui sera passé en paramètre de notre fonction.

À vos claviers ! Je vous préviens, cela sera assez difficile. N'hésitez pas à prendre du temps sur ce problème. Pour ceux qui ont fini, voici la correction.

[secret]{ En fait, il fallait trouver une petite astuce pour ne pas avoir à utiliser de tableau supplémentaire. Il faut utiliser une fonction qui échange deux éléments dans notre tableau, et l'appliquer sur les bons éléments. Cette fonction n'est rien d'autre que la fameuse fonction swap qu'on a créée dans l'exercice du chapitre sur les pointeurs. Pour rappel, la voici.

Il suffit de commencer par échanger le premier élément et le dernier, puis d’échanger le second avec l'avant-dernier, etc. Plus précisément, on doit échanger l'élément d'indice i avec l'élément d'indice (longueur - i - 1). Il suffit de continuer ainsi jusqu’au milieu du tableau, comme ceci. }


Les tableaux multidimensionnels

Dans le chapitre précédent, nous avons vu des tableaux simples, dans lesquels on plaçait nos éléments bouts à bouts les uns derrières les autres. Si on veux représenter ceux-ci, cela peut se faire sur une seule ligne, sur laquelle on placerait nos éléments dans l'ordre de leurs indices. Il s'agissait des tableaux à une dimension ou uni-dimensionnel.

Mais certaines données peuvent être représentées plus simplement sous la forme de tableaux à deux dimensions, organisées en lignes et en colonnes. Par exemple, c'est le cas d'une image. Comme autre exemple, vous pouvez prendre une grille de sudoku : celle-ci est organisée en 3 lignes et en 3 colonnes. Et on pourrait encore trouver d'autres exemples de données organisées en lignes et en colonnes. Le besoin de tableaux rectangulaires se fait sentir, et nos simples tableaux à une dimension ne semblent pas suffire : on doit utiliser des tableaux à deux dimensions. Et bien sachez que le langage C fournit de quoi gérer de tels tableaux. Pire : il fournit de quoi gérer des tableaux de données encore plus complexes, qui possèdent plus de deux dimensions. Ces tableaux, légèrement plus complexes, sont appelées des tableaux multidimensionnels. Ce chapitre va vous parler de ces tableaux et de leur utilité.

Grosso-modo, ces tableaux servent quand on a besoin de stocker des données organisées en lignes et colonnes, dans un joli rectangle. Les tableaux à plus de 2 dimensions sont eux beaucoup plus rares, et servent assez peu. Quoiqu'il en soit, nous allons tout de même voir ces tableaux.

  • Déclaration et initialisation
  • Accès à un élèment
  • Envoi à une fonction
  • Exercices

Déclaration et initialisation

Mine de rien, ces tableaux multidimensionnels se comportent comme des tableaux normaux et leur déclaration, initialisation, et utilisation s'effectue de façon similaire à ce qu'on a vu dans le chapitre sur les tableaux à une dimension. Voyons maintenant comment déclarer ces tableaux.

Déclaration

Pour déclarer un tableau à plusieurs dimensions, on procède comme pour les tableaux à une dimension, avec une toute petite différence. Il faut commencer par indiquer le type des éléments du tableau, puis spécifier l'identificateur, et enfin placer des crochets.

Seule différence : on doit utiliser autant de crochets qu'on veut de dimensions. Exemple : si je veux un tableau à deux dimensions, je dois préciser le nombre de lignes, et le nombre de colonnes. Le nombre de lignes sera indiqué entre crochets, et même chose pour le nombre de colonnes.

Exemple : un tableau de int de 20 lignes et de 35 colonnes donnera ceci :

1

int tableau2D [20][35] ;

Et l'on doit mettre autant de crochets que l'on veut de dimensions. En indiquant bien sur la taille entre les crochets, si besoin.

Exemples

Exemple avec un tableau à deux dimensions :

1

int tableau2D [2][3];

Et avec un tableau à trois dimensions :

1

int tableau3D [2][3][2];

Le principe est très simple puisque c'est le même que pour les tableaux à une dimension, mais adapté.

Initialisation

Initialiser un tableaux à plusieurs dimensions se fait à peu prêt de la même façon qu'avec les tableaux à une dimension.

Avec la taille de la première dimension

Comme pour les tableaux à une dimension, on peut initialiser ceux-ci en indiquant la taille du tableau. La différence est que cette précision doit se faire pour toutes les dimensions du tableau. Pour un tableau à deux dimensions, on doit ainsi indiquer le nombre de ligne et le nombre de colonnes.

La syntaxe pour l'initialisation des tableaux multidimensionnels ressemble à celle utilisée pour les tableaux à une dimension. Mais il y a tout de même une petite différence. Avec un tableau uni-dimensionnel, on effectuait une initialisation avec la syntaxe suivante :

1

type identificateur [nombre d’éléments] = { Valeurs par défaut, séparées par des virgules }

Cette syntaxe est réutilisée, mais ce qui est entre les {...} va changer. Ce qu'on initialisera, ce sont des lignes. Chacune de ces lignes est un tableau comme un autre, et notre tableau multidimensionnels peut être vu comme un tableau de tableaux. Et en conséquence, la syntaxe pour initialiser plusieurs lignes va être un peu spéciale. Prenons un exemple :

1

int tableau[2][2] = {{0,1}, {2,3}};

Ici, on demande à initialiser la première dimension avec les valeurs 0 et 1, et la deuxième dimension avec les valeurs 2 et 3. Pour faire simple, en gros, tout ce qui est entre { } sert à préciser le contenu d'un tableau. On trouve donc un paquets de { valeur , valeur, valeur , etc } pour chaque ligne. Et ce principe est le même pour tous les tableaux, multidimensionnels ou pas.

Il est également possible de ne préciser que certaines valeurs :

1

int tableau[2][2] = {{0}, {2}};

Toutes les valeurs non-précisées seront automatiquement mises à zéro. On peut d'ailleurs procéder à une mise à zéro de tout le tableau très rapidement et facilement :

1

int tableau[2][3][4] = {{{0}}};

Sans la taille de la première dimension

Il est possible lors de la déclaration de ne pas préciser la taille de la première dimension. Ainsi, ce code est parfaitement valide :

1

int tableau[][3] = {{1, 2, 3, 4, 5}, {6, 7, 8}};

Et dans ce cas, la première dimension vaudra 5. Cependant, cela ne marche qu'avec la première dimension. Si vous ne précisez pas la taille des autres dimensions, vous aurez une erreur.

Accès à un élèment

Maintenant que l'on sait comment déclarer et initialiser nos tableaux à plusieurs dimensions, il nous reste à voir comment manipuler leur contenu. On sait déjà le faire avec nos tableaux à une dimension, et passer à plusieurs dimensions n'est pas vraiment compliqué. La différence entre les tableaux à une dimension et ceux à plusieurs dimensions tient dans le nombre d'indices nécessaires pour repérer un élément dans notre tableau. Par exemple, si je prends un tableau à deux dimensions, je dois préciser l'indice de notre élément dans la ligne, et celui de la colonne. J'ai donc besoin de deux indices. Pour un tableau à trois dimensions, j'aurais besoin de trois indices. Et ainsi de suite : pour un tableau à N dimensions, j'aurais besoin de N indices. Pour rappel, tous les indices commencent à zéro.

Pour accéder à un élément, on doit faire la même chose que dans le cas d'un tableau à une dimension : on doit calculer son adresse, et déréférencer celle-ci. Pour ce faire, on peut tout à fait se contenter d'utiliser le formalisme tableau vu dans le chapitre précédent, d'une façon un peu adaptée. Mais il y a tout de même quelques subtilités cachées que nous avons décidés, nous auteurs, de vous révéler. Toutes ces subtilités viennent de la façon dont on calcule l'adresse d'un élément d'un tableau à deux dimension. Et pour calculer cette dernière, on est obligé de savoir comment nos tableaux à plusieurs dimensions sont stockés en mémoire.

Row Major Order

Prenons le tableau à deux dimensions suivant :

Comme vous le voyez, il s'agit d'un tableau d'entiers, à deux dimensions, comprenant trois lignes et trois colonnes. Comme on l'a dit plus haut, l'indice des lignes commence à zéro, tout comme l'indice des colonnes, et ceux-ci vont de 0 à 2. On peut de demander comment ses données sont organisées en mémoire. Et bien le langage C nous dit que toutes les données du tableau sont stockées les unes à coté des autres en mémoire. En clair : les données stockées dans un tableau à plusieurs dimensions sont rassemblées dans un tableau à une seule dimension.

Nous avons donc bien avancé. Ceci dit, il nous reste un petit détail à régler. Si je prends le tableau au-dessus, je peux parfaitement stocker celui-ci dans un seul tableau, mais deux deux façons différentes. Je peux tout stocker dans un tableau en mettant les colonnes les unes après les autres.

C'est cette solution qui est utilisée dans des langages de programmation comme FORTRAN, mais ce n'est pas le cas en C. En C, nos tableaux sont stockés lignes par lignes.

Maintenant que l'on sait cela, reste à savoir comment cela nous aide pour calculer l'adresse d'un élément.

Calcul d'adresse

Pour illustrer ces histoires de calcul d'adresse, nous allons prendre le cas des tableaux à deux dimensions. Passer aux dimensions supérieures est vraiment facile une fois qu'on sait ce qu'il faut faire pour les tableaux à deux dimensions.

Pour localiser une donnée dans un tableau à deux dimensions, on doit d'abord localiser le début de la ligne voulue, puis localiser la donnée dans cette ligne. Vu que nos lignes sont stockées les unes après les autres en mémoire, on peut considérer qu'un tableau à deux dimension est un tableau de ligne. Calculer l'adresse d'une ligne se fait alors très simplement en utilisant la formule vue dans le chapitre précédent. On pose $L$ la longueur d'une ligne, $i$ l'indice de la ligne, et $A$ l'adresse de début du tableau (l'adresse du premier élément). L'adresse de la ligne d'indice $i$ vaut $A + L \times i$.

Ensuite, il nous reste à localiser la donnée dans la ligne à partir de son second indice. Pour cela, on réutilise la formule vue dans le chapitre précédent. On pose $L$ la longueur de la donnée, $j$ l'indice de la ligne, et $AL$ l'adresse du début de la ligne. L'adresse de la donnée ayant pour second indice $j$ vaut $AL + T \times j$.

En combinant ces deux formules, on obtient la formule finale qui nous permet de calculer l'adresse de l’élément d'indice i et j : l'adresse de la donnée d'indices $i$ et $j$ vaut $A + L \times i + T \times j$.

En clair, cette adresse est assez difficile à calculer. On préférera le formalisme tableau pour accéder à un élément d'un tableau à deux dimensions (ou plus). Cela donne donc des expressions du style tab[i][j], voyez de vous-même :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

#include <stdio.h>

#define N 3
#define M 5

int main (void)
{
    int tab[N][M] = {{0}};
    int i, j;

    /* Pour chaque ligne */
    for (i = 0; i < N; i++)
    {
        /* Pour chaque case */
        for (j = 0; j < M; j++)
        {
            printf("%d ", tab[i][j]);
        }

        printf("\n");
    }

    return 0;
}

Ce programme affiche le contenu d'un tableau de dimension N et M.

Débordements chaotiques

Comme pour les tableaux à une dimension, déborder d'un tableau à plusieurs dimensions n'est pas sans conséquences. Le langage C ne fait aucune vérification, et il est facile de déborder d'un tableau à plusieurs dimensions sans s'en rendre compte. Et là encore, ce débordement est toujours un comportement indéterminé.

Envoi à une fonction

Formalisme pointeur

La première possibilité est celle-ci :

1

void fonction(int (*tab)[taille_dimension_2]);

Cette possibilité exploite juste le lien pointeurs-tableaux (*tab == tab[]). Les parenthèses sont importantes car si on ne les mets pas, on obtient un tableau de pointeurs à la place d'un pointeur de tableau ce qui est totalement différent. Pour des tableaux de dimension supérieure, le principe est le même :

1

void fonction(int (*tab)[taille_dimension_2][taille_dimension_3]);

Formalisme tableau

On peut aussi utiliser le formalisme tableau, comme ceci :

1

void fonction(int tab[taille_dimension_1][taille_dimension_2]);

Pour des tableaux de dimension supérieure, le principe est le même :

1

void fonction(int tab[taille_dimension_1][taille_dimension_2][taille_dimension_3]);

Encore une fois, le choix est votre concernant telle ou telle forme. Dans ce cours, nous utiliserons le formalisme tableau par soucis de clarté.

Exercices

Comme à l'accoutumée, voici une petite liste d'exercices vous permettant de bien retenir tout ce que vous avez après lors de ce chapitre.

Somme, produit, moyenne

Je vous propose en premier lieu de réaliser un programme qui calcule la somme et la moyenne de toutes les valeurs d'un tableau à deux dimensions, puis ensuite le produit de chaque ligne. Enfin, vous trouverez la valeur minimum et maximum du votre tableau multidimensionnel.

Somme des éléments d'un tableau

L'algorithme est très facile à trouver, donc je ne le donnerai pas. En revanche, la correction se trouve ici (à ne regarder qu'après avoir cherché suffisamment ou bien si vous voulez comparer avec votre code).

Moyenne des éléments

Pour bien fixer les choses, on va dire que calculer la moyenne d'un tableau, c'est calculer la somme de tous les éléments divisé par le produit des dimensions. Si c'est bien clair, vous devriez trouver le code très rapidement ! Sinon, il reste la solution.

Produit des lignes

Cet exercice n'est pas très compliqué, là encore si vous réfléchissez à ce qu'il faut faire et aux fonctions déjà codées, vous y arriverez sans problème. Besoin quand même d'une correction ? Elle est ici.

Minimum et maximum

Le but de cet exercice est le même que celui du chapitre précédent : retrouver le maximum et le minimum d'un tableau à deux dimensions. Néanmoins il y a plusieurs dimensions à parcourir. Je n'en dis pas plus, la solution est là au cas où.

Triangle de Pascal

Les triangles de Pascal sont des objets mathématiques amusants. Voici une petite animation qui expliquera mieux le principe que je ne pourrais le faire.

Explication des triangles de Pascal en image

Le but va être de pouvoir générer des triangles de Pascal de la taille que l'utilisateur veut (il faut néanmoins que celle-ci soit inférieure ou égale aux dimensions du tableau). Pour cela, je vais vous donner un algorithme que nous allons décrypter ensemble (oui implémenter bêtement des algorithmes tous faits a un intérêt assez limité).

Tout d'abord, la première case est toujours à 1, donc nos boucles ne commenceront pas à 0 puisqu'on ne s'en occupera pas. Ensuite, la première et la dernière case de chaque ligne vaut 1, donc on peut écrire ça déjà. Mais entre ces deux bouts, que se passe t-il ? Eh bien c'est simple : chaque case [i,j] est la somme de la case précédente ([i - 1 , j - 1]) et de la case [i + 1 , j]. Un pseudo-code vous aidera à y voir plus clair.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

n = taille du triangle de Pascal
Mettre la première case du tableau à 1

Pour i = 1, i < n, i = i + 1
    Mettre la première case de la ligne à 1

    Pour j = 1, j < n - i, j = j + 1
         La case [i,j] prend la valeur [i - 1, j - 1] + [i - 1, j]

    Mettre la dernière case de la ligne à 1

Enfin, n'hésitez pas à faire une petite fonction qui affiche le tableau pour vérifier. Bon courage, vous allez progresser !

[secret]{ Le code est relativement simple, reprenez-le à tête reposée s'il y a des choses que vous ne comprenez pas. Notez que dans la fonction d'affichage, j'évite d'afficher les 0 afin de rendre l'affichage plus lisible (bien entendu, il faut que le tableau de départ soit nul avant d'être passé à ces deux fonctions). }


Les chaînes de caractères

Dans ce chapitre, nous allons passer aux choses sérieuses : nous allons apprendre à manipuler du texte en langage C. Ce texte, il faut bien trouver une solution pour le représenter dans notre ordinateur. Et pour cela, le langage C fournit ce qu'on appelle des chaînes de caractères. Nous allons donc voir en détail ces chaînes, expliquer ce qu'elles sont, montrer comment en créer et donner quelques fonctions de base pour les manipuler. Que la fête commence !

  • Qu'est ce qu'une chaîne de caractères ?
  • Déclaration et initilisation
  • Utilisation d'une chaine
  • Conversion de chaînes de caractères en nombres
  • Lire et écrire dans une chaîne

Qu'est ce qu'une chaîne de caractères ?

Dans le chapitre sur les variables, j'avais mentionné le type char. Pour rappel, une variable de type char peut contenir une donnée qu'on peut interpréter soit comme un nombre, soit comme une lettre. Le seul problème, c'est qu'une variable de type char ne peut stocker qu'une seule lettre. Cela ne suffit pas stocker un roman de Victor Hugo dans son intégralité. Un vrai texte est composé de plusieurs lettres. Si on veut stocker un texte dans notre ordinateur, on doit trouver un moyen pour rassembler plusieurs lettres dans un seul objet, manipulable dans notre langage.

Rassembler plusieurs char serait difficile si on n'avait pas vu les chapitres précédents. Grâce à lui nous avons vu comment rassembler plusieurs variables d'un même type dans un seul objet : les tableaux. La solution à notre problème est toute trouvée. Pour rassembler plusieurs char ensemble, il suffit d'utiliser un tableau de char. Et c'est ainsi que le texte est géré en C. bien sûr, il y a moyen de faire autrement. On peut aussi représenter du texte par d'autres structures de données, mais passons. Quoi qu'il en soit, on obtient ainsi ce qu'on appelle des chaines de caractères, aussi appelées strings en anglais.

Pascal Strings

Quoi qu'il en soit, gérer des chaines de caractère stockées sous la forme de tableaux de char peut se faire de diverses manières. Ces manières se différencient par la façon dont on indique la fin de la chaine de caractère. Dans certains langages de programmation, les chaines de caractères sont stockées sous la forme d'un tableau de char auquel on a ajouté un entier pour indiquer sa longueur. C'est ainsi, notre chaine de caractère connait elle-même sa longueur, et on peut savoir quand on a atteint la fin de notre chaine de caractère en effectuant une comparaison entre l'indice du caractère courant avec la longueur de la chaine. De telles chaines de caractères sont souvent appelées des Pascal Strings, vu qu'elles sont utilisées telles quel dans le langage de programmation Pascal.

Null Terminated Strings

Mais ce n'est pas ce qui est fait en C ! En C, les chaines de caractères ne connaissent pas leur longueur. À la place, on préfère indiquer la fin de la chaine de caractère avec un caractère spécial. Ce caractère spécial se note '\0' (également noté 0 ou même '\x00'). Ce caractère est appelé caractère nul et marque la fin d'une chaîne de caractère. Les chaines de caractères qui fonctionnent su ce principe sont appelées des Null Terminated Strings, ou encore des C-Strings.

On peut se demander quelles sont les raisons qui ont mené à un tel choix. Il faut dire que ce choix à quelques désavantages. Lorsqu'on manipule des chaines de caractères, on a souvent besoin de leur longueur : cela permet d'éviter de déborder de la chaine pendant qu'on travaille dessus. Avec des chaines à la Pascal, cette longueur est immédiatement disponible. Mais avec des chaines à la C, on doit parcourir toute la chaine de caractère jusqu'au caractère nul pour en déduire sa longueur. C'est plus lent. D'ailleurs, presque tous les langages modernes (plus récents que le C) préfèrent utiliser des chaines à la Pascal.

Si les concepteurs du C ont choisi d'utiliser des Null Terminated Strings, c'est pour pouvoir gérer des chaines de caractères de plus grandes tailles sur le PDP-11, l'ordinateur sur lequel le C a été conçu. En effet, cet ordinateur possédait pas mal de mémoire, et pouvait gérer des chaines de grande taille. Mais, il ne pouvait gérer que des entiers non signés de 16 bits, qui allaient donc de 0 à 65535. S'ils avaient utilisé des chaines à la Pascal, ils auraient été limités par la taille de l'entier qui stocke la longueur. Avec les Null Terminated Strings, ce n'est pas le cas. Et en plus, les concepteurs du C trouvaient plus simple d'avoir à gérer des Null Terminated Strings.

Déclaration et initilisation

Déclaration

Déclarer une chaine de caractère, c'est déclarer un tableau de char. Il suffit de placer le mot-clé char, suivi de l'identificateur de la chaine de caractère à créer, et encadrer le tout par un [ suivi d'un ]. On doit aussi indiquer la taille de notre chaine entre les [ ], mais passons. Il n'y a rien de bien compliqué : relisez le chapitre sur les tableaux si vous avez oublié, vous verrez que c'est très simple.

1

char identificateur[taille];

Initialisation

Reste que l'on peut aussi initialiser notre chaine de caractère. Cela se fait comme pour un tableau, à un détail prêt. Quand on initialisait un tableau, on le déclarait, et on lui affectait des valeurs placées entre deux accolades et séparées par une virgule. Le tableau était alors initialisé avec les valeurs placées entre accolades.

1

int tableau[25] = {2, 5, 6, 878, 7897, 9};

Pour déclarer et initialiser une chaîne de caractères, on remplace les accolades par deux ", entre lesquels on place le texte que l'on veut écrire. On utilise donc la syntaxe suivante :

1

char identificateur[] = "du texte";

Cette notation est de nouveau du sucre syntaxique puisque elle nous évite de devoir initialiser chaque case du tableau manuellement, même si c'est possible. Ainsi, le code ci-dessus est strictement équivalent au code ci-dessous, la seule différence étant qu'il est plus rapide d'écrire le premier que le second.

1

char identificateur[] = {'d', 'u', ' ', 't', 'e', 'x', 't', 'e', '\0'} ;

Initialisation avec la taille

Bien entendu il est possible de préciser la taille du tableau à sa création. Il faut néanmoins se souvenir d'une règle importante : Taille du tableau = Nombre de caractères + '\0' final. Ainsi, si je veux stocker la chaîne "Hello world!", je dois créer un tableau de 13 cases, car ma chaîne comporte 12 caractères (car l'espace compte comme un caractère).

1

char chaine[13] = "Hello world!"

Dans ce cas, le caractère nul sera automatiquement ajouté et j'obtiendrai alors une chaîne parfaitement valide. On peut logiquement se demander ce qu'il se passe si la taille n'est pas suffisante. Il faut distinguer deux cas.

· Si la taille précisée est inférieure au nombre de caractères, alors il y a risque de plantage lors de l'utilisation de la chaîne. D'ailleurs, le compilateur devrait vous prévenir avec un message du style « warning: initializer-string for array of chars is too long », ce qui signifie que la chaîne que l'on veut stocker est trop grande par rapport à la taille du tableau.

· Si la taille précisée est égale au nombre de caractères, alors on obtient non pas une chaîne de caractère, mais un tableau de caractères. Il s'agit simplement d'un tableau qui contient divers caractères, mais qui n'est pas terminé par le caractère nul. Bien entendu, cela peut également amener des problèmes quand on tente de les manipuler.

Une autre façon

Comme l'indique le titre, il existe une autre façon de déclarer une chaîne de caractères : un pointeur sur char. La syntaxe est similaire à celle du tableau :

1

char * ptr = "du texte" ;

On peut donc logiquement se demander s'il existe des différences entre les deux formes et si oui lesquelles. C'est ce que nous allons voir dans la partie suivante.

Utilisation d'une chaine

Les différences entre les deux formes

Alors qu'elles semblent identiques à première vue, les deux formes possèdent des différences assez importantes. Considérons les deux déclarations suivantes.

1
2

char tab[20] = "hello";
char * ptr = "hello";

Hormis les différences vues dans le chapitre précédent, c'est surtout le contenu des deux déclarations qui est intéressant. Dans le premier cas, la notation "hello" correspond à {'h', 'e', 'l', 'l', 'o', '\0'} alors que la deuxième notation est ce que l'on appelle une constante chaîne.

Une constante chaîne est en réalité un tableau de char anonyme constant. Cela veut dire que le pointeur ptr pointe sur la première case d'un tableau quelque part en mémoire qui contient la chaîne "hello". Comme ce tableau est constant, il est impossible de modifier ses éléments. Ainsi, dans le code suivant, la deuxième ligne provoquera un plantage du programme :

1
2
3
4

tab[1] = 'a';
ptr[1] = 'a';

/* Le résultat est identique avec la forme *(ptr + 1) = 'a' */

Pour éviter ces plantages, le mieux est de prendre l'habitude de déclarer constant les pointeurs de char. Ainsi, dès que l'on tentera de les modifier, la compilation plantera en affichant une erreur du type « error: assignment of read-only location '(ptr + 1)' »*.

1
2

const char * ptr = "hello";
ptr[1] = 'a';

L'autre différence réside dans le fait que ptr peut se faire affecter une nouvelle valeur, tandis que tab ne le peut pas (ce que nous avons dis dans le chapitre sur les tableaux s'applique ici aussi). Ainsi, dans le code suivant, la deuxième affectation est bonne au contraire de la première.

1
2
3
4
5

char tab[20] = "hello";
char * ptr = "hello";

tab = "world";
ptr = "world";

Pour modifier un tableau de char, il faut faire comme n'importe quel tableau à savoir modifier les éléments un par un :

1
2
3
4
5
6
7
8

char tab[10] = "hello";

tab[0] = 'w';
tab[1] = 'o';
tab[2] = 'r';
tab[3] = 'l';
tab[4] = 'd';
tab[5] = '\0';

Si cela vous intéresse vous pouvez trouver un complément d'information sur ce topic.

Lire et écrire

Les fonctions printf et scanf disposent d'un indicateur de conversion qui permet d'afficher ou de demander une chaîne de caractère : %s. Le programme suivant permet d'afficher Bonjour à l'écran :

1
2
3

/* ou mieux : const char * chaine = "Bonjour"; */
char chaine[] = "Bonjour";
printf("%s", chaine);

Chaînes et scanf

Pour interagir avec l'utilisateur avec la fonction scanf, on utilise le même indicateur de conversion. Cependant, il faut penser à réserver un espace suffisant lors de la création du tableau pour permettre à l'utilisateur de rentrer une chaîne de caractères sans écraser des données en écrivant après le tableau. Comme il n'est pas possible de connaitre la taille exacte de ce que l'utilisateur va rentrer, on est obligé de créer un grand tableau de char :

1
2
3
4
5

char chaine[256];

printf("Donnez votre prenom : ");
scanf("%s", chaine);
printf("Vous vous appelez %s\n", chaine);

Petite remarque : pour les chaînes de caractères, on utilise pas le & dans le scanf car on donne déjà l'adresse de la première case du tableau. Ce code affichera par exemple :

1
2

Donnez votre prénom : Litchi
Vous vous appelez Litchi

Dans cet exemple, l'utilisateur peut rentrer jusqu'à 255 caractères (car la dernière case est réservée pour le '\0'). Si jamais il dépasse cette limite, on court alors les mêmes risques que lorsqu'on déborde d'un tableau, ce qui est normal, vu qu'une chaîne est un tableau de char. Cela va de rien (avec beaucoup de chance) à des données écrasées voire un plantage pur et simple du programme. Nous verrons plus tard comment éviter ces problèmes en sécurisant la saisie de données. En attendant, on ne peut que prévoir un grand tableau.

Conversion de chaînes de caractères en nombres

Dans ce qui va suivre, nous allons vous montrer quelques fonctions simples qui manipulent des chaines de caractère. Ces fonctions que nous allons voir permettent de convertir une chaîne de caractères en nombre. Elles sont définies dans <stdlib.h>. A quoi peuvent-elles bien servir ? Elles s'avèrent utiles dans le cas d'une calculatrice par exemple : l'utilisateur rentre une chaîne de caractère qui contient des nombres, ceux-ci sont convertis en valeurs numériques, et on peut effectuer des calculs sur eux.

strtol - Convertir une chaîne en long

Commençons par strtol. Cette fonction lit une chaîne de caractères et tente de la convertir en long. Elle s'arrête au premier caractère qui n'est pas un chiffre, un signe +, - ou un espace.

Paramètres

Cette fonction prend trois paramètres, qui ont chacun une utilité assez spéciale. Le premier paramètre est la chaîne de caractères que l'on souhaite convertir. Elle n'est pas modifiée par la fonction.

Ensuite, elle prend un argument de type pointeur de pointeur sur char (c'est à dire char**), qui sert à récupérer la position du premier caractère qui n'est pas un nombre. Si toute la chaîne a pu être convertie, alors le paramètre vaut '\0'. Si on ne veut pas récupérer les caractères invalides, il suffit de passer NULL en argument à la fonction.

Enfin, le dernier paramètre est la base dans laquelle on veut convertir la chaîne, qui doit être comprise dans l'intervalle $[2 ; 32]$. On peut ainsi convertir une chaine en binaire (base 2), en trinaire (base 3), en octal (base 8), en hexadécimal (base 16), etc.

Prototype

On sait maintenant à quoi ressemble le prototype de la fonction :

1

long strtol(const char * nptr, char ** endptr, int base);

stroul

Il existe une variante de cette fonction pour convertir une chaîne en unsigned long cette fois : strtoul. Cette fonction prend les mêmes paramètres que strtol, seul la valeur de retour change :

1

unsigned long int strtoul(const char * nptr, char ** endptr, int base)

Voici un programme qui demande une chaîne de caractères et une base avant de tenter de convertir cette chaîne en nombre, suivi de quelques résultats.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char tab[256];
    char * ptr;
    int base;
    long n;

    puts(">> Rentrez une chaine");
    scanf("%s", tab);
    puts("Puis une base [2;32]");
    scanf("%d", &base);

    n = strtol(tab, &ptr, base);
    printf("\nNombre en base %d = %ld\n"
           "Caracteres invalides : %s\n", base, n, ptr);

    return 0;
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

>> Rentrez une chaine
123456
Puis une base [2;32]
10

Nombre en base 10 = 123456
Caractères invalides :

>> Rentrez une chaine
12345Salut
Puis une base [2;32]
8

Nombre en base 8 = 5349
Caractères invalides : Salut

>> Rentrez une chaine
Hello123
Puis une base [2;32]
24

Nombre en base 24 = 243597
Caractères invalides : o123

>> Rentrez une chaine
101001
Puis une base [2;32]
4

Nombre en base 4 = 1089
Caractères invalides :

strtod - Convertir une chaîne en double

Ensuite, on doit parler de la fonction strtod. Cette fonction va convertir une chaine de caractère en double. Elle fonctionne presque comme les fonctions strtol et strtoul. Le comportement est identique mais les paramètres sont néanmoins différents.

Paramètres

Le premier paramètre est toujours la chaîne de caractère que l'on souhaite convertir, le deuxième est toujours le pointeur de pointeur sur char qui sert à récupérer les caractères invalides, mais le troisième n'existe plus.

Prototype

On sait maintenant à quoi ressemble le prototype de la fonction :

1

double strtol(const char * nptr, char ** endptr);

Lire et écrire dans une chaîne

Pour lire et écrire des données, nous avons vu entre autre printf et scanf, qui permettent respectivement d'écrire et de lire autant de données que l'on souhaite. Sachez que ces deux fonctions font en réalité partie d'une famille de plusieurs fonctions, toutes définies dans <stdio.h>. Nous allons en découvrir deux.

sprintf - Écrire dans une chaîne

La fonction sprintf, s signifiant string, est comme son nom l'indique une fonction qui permet d'écrire des données dans une chaîne de caractères. Son prototype, qui est un peu particulier, requiert certaines connaissances que vous pouvez acquérir en lisant ce tutoriel . N'hésitez pas à le garder dans un coin, il pourra toujours vous être utile un jour. Le voici :

1

int sprintf (char * str, const char * format, ...);

Elle fonctionne exactement comme printf, à ceci près que les arguments seront écrits dans str, qui bien entendu doit être de taille suffisante. Enfin, elle retourne le nombre de caractères inscrits ou bien 0 si aucune conversion n'a réussi.

Une question vous vient peut-être à l'esprit : "Mais à quoi peut bien servir cette fonction ?". En effet, à première vue, écrire dans une chaîne de caractères n'a pas grand intérêt. Je vais vous prouver le contraire avec un exemple. Dans la partie précédente, nous avons vu comment convertir une chaîne de caractères en nombre. Grâce à sprintf, il est possible de faire l'inverse.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    char str[10];
    int n = 5;

    sprintf(str, "%d", n);
    puts(str);

    return 0;
}

Essayez ce programme et vous verrez que sprintf a bien écrit le chiffre 5 dans str. Vous pouvez maintenant convertir les nombres en chaînes de caractères de manière portable et efficace !

sscanf - Lire dans une chaîne

La fonction sscanf permet, comme sa cousine, de manipuler des chaînes de caractères mais cette fois pour les lire et récupérer des données. Voici son prototype :

1

int sscanf (const char * str, const char * format, ...);

Elle renvoie le nombre de caractères correctement lus, ou bien 0 si rien n'a pu être converti. Voici un exemple d'utilisation dans lequel on récupère des données dans une chaîne de caractères :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <stdio.h>

int main(void)
{
    char str[10];
    int n = 0;
    const char * s = "5 abcd";

    sscanf(s, "%d %s", &n, str);
    printf("%d %s\n", n, str);

    return 0;
}

Si vous exécutez le programme, vous remarquerez que la variable n vaut maintenant 5 et que str contient bien "abcd". L'intérêt de cette fonction ? Elle permet par exemple de récupérer des données rentrées par un utilisateur comme dans le cas d'une calculatrice ou d'un serveur.


L'en-tête <string.h>

Nous savons déclarer des chaînes de caractères et les initialiser, mais pour l'instant, ça s'arrête là. Dans cette partie, nous allons découvrir quelques fonctions de la bibliothèque standard qui permettent d'effectuer des opérations diverses sur les chaînes, comme calculer la taille ou bien copier une chaîne dans une autre. Tous les prototypes de ces fonctions sont définis dans le fichier d'en-tête <string.h>. Pour pouvoir les utiliser, vous allez devoir 'inclure comme ceci :

1

#include <string.h>

Chose étonnante, <string.h> ne contient pas que des fonctions qui manipulent des chaînes de caractères : ce fichier d'en-tête fourni aussi des fonctions qui travaillent directement sur des blocs de mémoire. Nous allons découvrir tout cela au cours de ce chapitre.

  • Longueur
  • Copie
  • Comparaisons
  • Recherche
  • Autres
  • Exercices

Longueur

La première fonction que nous allons examiner est strlen, comme string lengh, soit longueur de chaîne. Cette fonction va nous permettre de récupérer la taille de n'importe quelle chaîne de caractères sans compter le '\0' final.

Résultat

La fonction retourne un size_t. Pour ceux qui ne se rappellent pas, size_t est un type qui sert à stocker les tailles de données assez grosses, comme les tableaux ou les chaînes de caractères. C'est donc normal que strlen donne un résultat de type size_t. Il a été inventé parce que sur certaines machines, il est possible de créer des données tellement grosses qu'on ne peut pas stocker leur taille dans un simple int ou un unsigned int.

Argument

Comme on le déduit de son utilité, cette fonction va prendre en argument une chaine de caractère dont il faut donner la taille. Ce paramètre est de type const char * : on a un pointeur sur char constant. Le lien entre pointeurs et tableaux se retrouve encore une fois. A notez la présence de const, qui empêchera toute modification, volontaire ou non, de la chaîne.

Prototype

De ce qu'on vient de dire précédemment, on en déduit que son prototype est le suivant :

1

size_t strlen (const char * str);

Exemple

Illustrons ce code par un exemple. Je veux connaitre la taille de cette chaîne : "Bonjour les amis!". J'utilise donc strlen qui me renverra le nombre de caractères de cette chaîne.

1
2
3

char chaine[] = "Bonjour les amis!";

printf("Taille de la chaîne = %u\n", (unsigned) strlen(chaine));

1

Taille de la chaine = 17

Copie

strcpy - Copier une chaîne

Après strlen, qui permet de connaitre la longueur d'une chaine, voici le tour de strcpy. La fonction strcpy copie une chaîne dans une autre chaîne et renvoie un pointeur sur cette dernière. Elle copie tous les caractères, y compris le caractère nul '\0'. Pour vous souvenir de ce que cette fonction fait, vous devez savoir que strcpy est l'abréviation de strings copy soit copie de chaînes.

Prototype

Son prototype est le suivant :

1

char * strcpy(char * dest, const char * src);

Ses deux arguments sont d'abord la chaine de destination, puis la chaine source.

Débordements chaotiques

Cependant, strcpy a un léger problème. Elle ne fait aucune vérification sur les longueurs des chaînes sources et de destination. Si la chaîne de destination est trop petite pour recevoir toute la chaîne d'origine, la fonction strcpy ne s'en aperçoit pas. Conséquence : elle écrase les données situées après. Par exemple, voici un exemple de copie qui fonctionne, mais qui écrase des données :

1
2
3
4

char bug[5];

/* chaîne de destination trop petite donc problème */
strcpy(bug, "bonjour");

Pour information, certains pirates utilisent ce comportement pour faire exécuter des programmes malicieux. C'est ce qu'on appelle l'attaque par buffer overflow. Cela peut sembler ennuyeux, et dans les faits ça l'est.

Autre détail : si jamais les deux chaines se recouvrent (c'est-à-dire si un morceau de la première chaine est inclus dans la seconde ou réciproquement), le comportement de strcpy est indéfini. En clair : les deux chaînes doivent être séparées et bien distinctes et doivent occuper des zones de mémoire bien séparées, sans recouvrements. Par exemple, si on souhaite copier une chaine dans elle-même, le résultat de strcpy est indéterminé. Même chose si on veut copier un morceau d'une chaine dans elle-même.

Si les concepteurs du C ont décidés que strcpy aurait ce comportement, c'est encore une fois pour des raisons de performances. Effectuer des vérifications sur les longueurs des chaînes sources et de destinations, ça prend du temps. Et dans un langage conçu pour la performance, c'est niet !

strncpy - Copie partielle d'une chaîne

Passons maintenant à la fonction strncpy. Cette fonction est quasiment identique à la précédente, sauf que strncpy ne copie qu'un certain nombre de caractères. Au lieu de copier toute la chaine source dans la chaine de destination (comportement de strcpy), on peut demander à strncpy de ne copier que $n$ caractères.

Prototype

Voici le prototype de cette fonction :

1

char * strncpy(char * dest, const char * src, size_t n);

Comme on le voit, la valeur de retour est la même que pour la fonction strcpy : il s'agit d'un pointeur qui pointe sur la chaine de destination.

Arguments

Passons maintenant aux arguments. Les deux premiers arguments sont, dans l'ordre, la chaine de destination, suivie de la chaine source. Le troisième argument est le nombre de caractères à copier.

Conséquences imprévues

Si cette fonction peut appraraître comme une bonne alternative à la fonction strcpy de prime abord, sachez qu'il n'en est rien ! Tout d'abord, celle-ci souffre des même problèmes que strcpy : d'une part, elle ne fait aucune vérification sur la longueur respective des chaines et, d'autre part, si jamais les deux chaines se recouvrent, son comportement est indéterminé. Ensuite, elle dispose de son propre lots de problèmes : si la chaîne source est plus petite que la chaîne de destination, elle ajoute des caractères nuls en lieu et place des caractères manquants (ce qui nuit aux performances) ; à l'inverse, si la chaîne de destination est plus petit que la chaîne source et que la taille maximale spécifiée correspond à celle de la chaine de destination, cette dernière ne sera pas terminée par un caractère nul.

Vous me demanderez sans doute pourquoi cette fonction existe si elle pose autant problème ? La réponse est simple : elle n'a en vérité pas été conçue pour la manipulation de chaîne de caractères, mais pour la manipulation de champ de taille fixe (la notion de champ sera discutée dans le chapitre consacré aux structures). En bref, oubliez cette fonction lorsque vous manipulez des chaînes de caractères.

Comparaisons

strcmp - Comparer deux chaînes

La fonction strcmp permet de comparer deux chaînes de caractères, lettre par lettre. Pour s'en souvenir, il suffit de se dire que strcmp est l'abréviation de strings compare, qui veut dire comparaison de chaînes en anglais.

Argument

Cette fonction strcmp prend deux arguments : ces deux arguments ne sont rien d'autre que les deux chaînes à comparer. Comme on ne modifie pas les chaînes, ils sont tous les deux constants.

Valeur de retour

Petit détail : cette fonction ne renvoie pas un booléen, comme on pourrait s'y attendre. Elle renvoie une valeur entière qui sera négative, nulle ou positive si la première chaîne est respectivement plus petite, de même taille ou plus grande que la deuxième chaîne. Elle se base sur l'ordre alphabétique des lettres pour comparer les deux chaines de caractères.

Prototype

Voici donc son prototype :

1

int strcmp(const char * chaine1, const char * chaine2);

Exemple

Illustrons son utilisation par un exemple très simple où je compare deux chaînes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

char chaine1[] = "bonjour";
char chaine2[] = "bonsoir";

if (strcmp(chaine1, chaine2) == 0)
{
    puts("Les chaines sont identiques.");
}

else
{
    puts("Les chaines sont differentes.");
}

Ce code affiche, sans surprise :

1

Les chaines sont différentes.

strncmp - Comparer deux chaînes partiellement

Il existe une variante de la fonction strncmp qui permet de comparer des portions de chaines de caractères au lieu de chaines de caractères complètes. Plus précisément, strncmp permet de ne comparer que les $n$ premiers char de nos deux chaînes. Cela permet de vérifier que les $n$ premiers caractères sont identiques ou différents.

Valeur de retour

Son comportement est identique à strcmp : elle renvoie une valeur entière qui sera négative, nulle ou positive si la première chaîne est respectivement plus petite, de même taille ou plus grande que la deuxième chaîne.

Arguments

Cette fonction prend 3 paramètres. Elle prend notamment des pointeurs vers les deux chaînes de caractères à comparer. Le dernier paramètre est le nombre de char qu'il faut comparer, le fameux nombre $n$ mentionné plus haut. Par exemple, si jamais je ne veux comparer que les 3 premières lettres, j'enverrais 3 comme paramètre. Si au contraire je veux en comparer 5, alors j'enverrais 5 à la fonction.

Prototype

Voici son prototype :

1

int strncmp(const char * s1, const char * s2, size_t n);

Exemple

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

char chaine1[] = "bonjour";
char chaine2[] = "bonsoir";
const int n = 3;

if (strncmp(chaine1, chaine2, n) == 0)
{
    puts("Les deux chaines sont identiques.");
}

else
{
    puts("Les deux chaines sont differentes.");
}

1

Les deux chaines sont identiques.

On demande à comparer les 3 premiers octets, et vu que les trois premiers caractères sont identiques, l'ordinateur affiche que les chaînes sont identiques. Si on avait pris une valeur plus élevée, il aurait affiché « Les deux chaines sont différentes ».

Recherche

strchr - Rechercher un caractère

La fonction strchr permet de rechercher un caractère en particulier dans une chaîne. Pour vous en souvenir plus efficacement, il suffit de savoir que strchr est l'abréviation de string character.

Valeur de retour

Si notre fonction trouve le caractère à chercher, elle renvoie un pointeur sur ce caractère, sinon elle renvoie NULL ; si le caractère est présent plusieurs fois, elle renvoie un pointeur sur la première occurrence de ce caractère. Chose importante à retenir : toujours bien vérifier que la fonction ne retourne pas NULL et agir en conséquence si c'est le cas.

Paramètres

Le premier paramètre est la chaîne dans laquelle on doit effectuer la recherche, le deuxième paramètre étant le caractère à rechercher. Il est de type int, ce qui ne change rien car au final les lettres sont des chiffres pour l'ordinateur.

Prototype

De ce qu'on vient de dire précédemment, on en déduit que son prototype est le suivant :

1

char * strchr (const char * str, int c);

Exemple

Illustrons par un exemple : je veux chercher la première occurrence de la lettre 'l' dans la phrase "Cours sur le langage C".

1
2
3
4
5
6
7
8
9

char chaine[] = "Cours sur le langage C";
char * str = strchr(chaine, 'l');

if (str != NULL)
{
     /* notation équivalente à str[0], revoyez le chapitre
      sur les tableaux si vous ne vous en rappelez plus */
     printf("%c\n", *str);
}

Comme strchr a trouvé le caractère que je cherchais, elle a retourné un pointeur sur ce caractère.

Remarque

Au fait, petite astuce : rien n’empêche de considérer ce pointeur comme un pointeur sur une chaine. Dans ce cas, ce pointeur pointe sur le reste de la chaine de caractère, lettre 'l' inclue. Ainsi, le code suivant :

1
2
3
4
5
6
7

char chaine[] = "Cours sur le langage C";
char * str = strchr(chaine, 'l');

if (str != NULL)
{
     puts(str);
}

affiche ceci :

1

le langage C

strrchr - La cousine de strchr

Au fait, strchr possède une cousine. La fonction strrchr fait la même chose mais elle retourne un pointeur sur le dernier caractère rencontré. C'est la seule différence ; tout ce qui a été dit précédemment est valable aussi pour strrchr.

strpbrk - Rechercher une liste de caractères

Avec la fonction strchr vue précédemment, on sait trouver la première occurrence d'un caractère dans une chaine (ou la dernière, avec strrchr). C'est très bien, mais on peut faire mieux. Imaginez que je veuille faire la même chose, non pas avec une seule lettre, mais avec plusieurs. Par exemple, avec strchr, je peux savoir où se situe le premier 'l' dans une chaine. Mais pour trouver où se situe la première lettre qui est soit un 'l', soit un 'a', je ne peux le faire avec strchr tout seul. On peut ruser, et utiliser un petit bout de code, mais le fait est que cette situation a déjà été prévue par les concepteurs du C. Pour ce faire, on a inventé la fonction strpbrk.

Valeur de retour

Cette fonction est différente de strchr et strrchr en un point : au lieu de ne rechercher qu'un seul caractère, elle recherche le premier caractère parmi une liste de caractères. Si elle trouve un des caractères à chercher, elle renvoie un pointeur sur ce caractère. Sinon elle renvoie NULL.

Prototype

Son prototype est le suivant :

1

char * strpbrk(const char * str, const char * c);

Paramètres

Le premier paramètre est la chaîne dans laquelle on doit effectuer la recherche, le deuxième paramètre étant la liste de caractères à rechercher.

Exemple

Prenons un exemple : je veux chercher les caractères 'u', 't' et 'l' dans la phrase "Salut aux lecteurs !".

1
2
3
4
5

char chaine[] = "Salut aux lecteurs !";
char * str = strpbrk(chaine, "utl");

if (str != NULL)
     printf("Premiere occurrence de u, t ou l : %s\n", str);

Comme la fonction a trouvé un des caractères que l'on voulait, elle s'arrête et renvoie un pointeur sur ce caractère :

1

Première occurrence de u, t ou l : lut aux lecteurs !

Là encore, la seule chose à retenir est de vérifier le retour de la fonction.

strstr - Rechercher une chaîne dans une autre

Et enfin, nous terminons cet aperçu des fonctions de recherche en parlant de strstr. La fonction strstr va rechercher si une chaîne de caractères est inclue dans une autre. Son rôle est donc de rechercher des sous-chaînes.

Par exemple, si je prends la chaine "le langage C", je sais que la chaîne "langage" est inclue dedans. Pareil pour les chaînes "lang", "gage C", etc.

Valeur de retour

Si elle trouve la chaîne à chercher, elle renvoie un pointeur sur cette chaîne, sinon elle renvoie NULL.

Paramètres

Le premier paramètre est la chaîne dans laquelle il faut effectuer la recherche, et le deuxième est la chaîne à rechercher.

Prototype

Son prototype est le suivant :

1

char * strstr(const char * str1, const char * str2);

Exemple

Supposons que je veuille chercher le mot "langage" dans la phrase "Cours sur le langage C".

1
2
3
4
5

char chaine[] = "Cours sur le langage C";
char * str = strstr(chaine, "langage");

if (str != NULL)
     puts(str);

Comme la fonction a trouvé la chaîne à chercher, elle s'arrête et renvoie un pointeur sur cette chaîne :

1

langage C

Il n'y a pas beaucoup de différence avec la fonction précédente, si ce n'est que strstr recherche toute la chaîne et pas seulement un ou plusieurs caractère(s).

Autres

strcat - Concaténer deux chaînes

La fonction strcat sert à concaténer deux chaînes, c'est à dire simplement coller deux chaînes l'une à la suite de l'autre. Par exemple, si je concatène les chaînes "im" et "puissant", j'obtiendrais la chaîne "impuissant". Autre exemple : si je concatène la chaîne "abc" avec la chaîne "def", j'obtiendrai "abcdef".

Pour vous souvenir de ce que fais cette fonction, sachez que srtcat est l'abréviation de strings concatenation, ce qui signifie concaténation de chaînes en anglais.

Prototype

Le prototype de strcat est le suivant :

1

char * strcat(char * dest, const char * src);

Ce prototype est assez instructif. Il nous apprend que notre fonction strcat va concaténer le contenu d'une chaîne source à la suite d'une chaîne de destination. Elle renvoie un pointeur sur cette dernière. La chaîne source n'est pas modifiée, mais la chaîne de destination l'est.

Exemple

Illustration avec un exemple dans lequel je veux concaténer les phrases "bon" et "jour" :

1
2
3
4
5
6

char dest[50] = "bon";
const char * src = "jour";

printf("Avant : %s\n", dest);
strcat(dest, src);    /* on concatène */
printf("Apres : %s\n", dest);

Ce qui donne ceci à l'écran :

1
2

Avant : bon
Après : bonjour

Attention !

Tout comme strcpy, la fonction strcat ne contrôle pas la longueur des chaînes passées en paramètre. Soyez donc prudent pour ne pas écraser des données.

strncat - Concaténer partiellement

Je ne sais pas si vous vous souvenez (mais vous devriez), mais strcpy avait une cousine du nom de strncpy, qui ne copiait qu'une partie de la chaîne à copier. Et bien strcat possède aussi une cousine du même genre. Il s'agit de la fonction strncat.

Cette fonction fait la même chose que la précédente, sauf qu'elle ne concatène qu'une partie de la chaîne de destination. Au lieu de placer toute la chaîne source à la suite de la chaine de destination (comportement de strcat), on peut demander à strncat de ne copier que $n$ caractères.

Prototype

Voici son prototype :

1

char * strncat(char * dest, const char * src, size_t n);

Comme on le voit, son prototype est identique à celui de strcat, le seul nouvel argument étant le nombre de caractères à concaténer.

Exemple

1
2
3
4
5
6
7
8

char dest[50] = "bon";
const char * src = "jour";
int n = 2;

printf("Avant : %s\n", dest);

strncat(dest, src, n);
printf("Resultat : %s", dest);

1
2

Avant : bon
Resultat : bonjo

Attention bis

Tout comme strcat, la fonction strncat ne vérifie pas la taille des chaînes passées en paramètres. Évitez donc de déborder.

Exercices

Palindromes

Un palindrome, vous savez ce que c'est ? Non, ce n'est pas une race de lutin. C'est simplement un texte qui se lit de la même façon qu'on le lise de droite à gauche. Par exemple, le mot RADAR est un palindrome. Mais cela fonctionne aussi pour les phrases. C'est le cas pour les phrases suivantes : "un art luxueux ultra nu", "Engage le jeu que je le gagne", etc. D'ordinaire, on ne tient pas compte des accents, trémas, cédilles ou des espaces. Mais pour cet exercice, on utilisera une définition plus stricte d'un palindrome : on tiendra compte des espace, trémas, etc.

Énoncé

Votre mission, si vous l'acceptez, sera de créer une fonction capable de vérifier si une chaîne de caractères est un palindrome. Celle-ci renverra 1 si la chaîne de caractère passée en entrée est bien un palindrome ou zéro si non.

Correction

Ne regardez la correction qu'après avoir vraiment cherché.

Changeons de registre, jouons au validateur

Connaissez-vous le validateur de xHTML ? Il s'agit d'un programme, présenté sous la forme de site web, qui vérifie que le code HTML d'une certaine page web est valide, autrement dit que c'est bel et bien du HTML.

Les pages sont composées de balises, comme <strong> ou <h1>, qui doivent impérativement être refermées (respectivement par </strong> et </h1>). C'est la première règle du HTML et d'ailleurs la première vérifiée par le validateur : toutes les balises doivent être refermées. Nous allons tenter d'écrire un programme qui vérifie cette règle.

Votre mission

Cependant, le HTML est un langage trop compliqué pour notre niveau actuel. Nous allons donc considérer un langage simplifié : celui des parenthèses. Eh oui, les programmeurs et les mathématiciens connaissent bien cette règle qui veut que toutes les parenthèses doivent être refermées. Nous allons donc écrire un vérificateur de parenthèses. C'est certes moins exaltant que le validateur, mais le principe de base est rigoureusement le même, le reste étant des détails concernant la lecture des balises.

Écrivez un programme qui lit une ligne constituée de parenthèses, et vérifie que chaque parenthèse est bien refermée. Par exemple,

1
2
3

Entrez une expression avec des parenthèses :
> )))(((
L'expression n'est pas bien parenthésée.

Pour corser le travail, vous pouvez également ajouter des crochets [] et des accolades {}. Ce travail est réalisé par toutes sortes de compilateurs et validateurs.

Un indice ?

Encore et toujours, il faudra lire les caractères en boucle. Mais après ? Compter les parenthèses ouvrantes et les parenthèses fermantes ne suffit pas. Autrement, une expression telle que ")))(((" serait acceptée, alors qu'elle n'est pas bien parenthésée. En réalité, il suffit de compter le nombre de parenthèses encore à refermer. S'il vous en reste zéro à la fin, tout va bien. Sinon, ou si vous en avez un nombre négatif, problème !

Correction !

Tada ! Et voici la correction !

Cette correction est un petit peu plus longue que les autres. N'hésitez pas à l'expérimenter chez vous, à essayer de l'améliorer. Si cet exercice vous a plus, sachez qu'il est inspiré d'un domaine appelé les langages formels. Nous avons écrit un automate reconnaissant le langage de Dyck. C'est plus impressionnant dit comme cela, mmh ?

Recodons la bibliothèque standard

Pour s'entraîner, on va recoder quelques fonctions que l'on a vu dans ce chapitre. Pour que ce ne soit pas trop long, je vous propose d'en recoder quatre : strlen, strcpy, strcat et strcmp. Si vous le souhaitez, vous pouvez également coder leurs cousines respectives,voire coder les autres fonctions que nous avons vu dans ce chapitre.

L'exercice n'est pas compliqué, réfléchissez simplement à ce que fait la fonction de la bibliothèque standard et comment reproduire son comportement. Bonne chance à tous !

Correction

Alors cet exercice, ça été ? Oui, non ? L'essentiel est que vous ayez cherché, même si vous n'avez pas trouvé, ou seulement à moitié. N'hésitez pas à réessayer plus tard, avec d'autres fonctions, c'est comme ça que vous y arriverez. En attendant, voilà la correction :

  • de my_strlen ;
  • de my_strcpy ;
  • de my_strcat ;
  • et de my_strcmp.


L'allocation dynamique

Dans les chapitres précédents, nous avons vu comment créer des structures, des tableaux et des chaînes de caractères. Mais il y a un petit problème : comment faire si on désire avoir un tableau (ou une chaîne de caractère) dont la taille n'est pas fixée une bonne fois pour toute ? C'est possible, mais on ne sait pas encore comment.

De plus, nos structures de données complexes, comme les tableaux, chaînes de caractères, et structures, n'ont pas besoin d'exister durant toute la vie d'un programme. Pas mal d'entre elles doivent avoir une vie éphémère : ces données complexes peuvent être crées et disparaitre suivant les besoins.

On peut se demander pourquoi ce comportement. La raison est simple : nos données complexes prennent de la mémoire RAM, qui est en quantité limitée. Réserver de façon permanente de la mémoire pour chaque donnée dont on aurait besoin reviendrait à se tirer une balle dans le pied. Pour limiter la casse, il est possible de réserver une portion inutilisée de la mémoire pour stocker temporairement des données complexes. Quand un programme a besoin d'un peu plus de mémoire pour stocker une donnée complexe, il peut ainsi réserver une partie vide de la mémoire, et se l'approprier pour stocker une donnée. Quand on n'a plus besoin de cette mémoire, on la libère, et elle sera réutilisable à volonté. C'est ce qu'on appelle l'allocation dynamique.

Comme vous vous en doutez, ce chapitre parlera de cette fameuse allocation dynamique. Vous verrez qu'il en existe plusieurs formes, et vous verrez aussi comment l'utiliser. Vous apprendrez notamment à utiliser certaines fonctions d'allocation dynamique disponibles en C.

  • Durée de vie
  • Malloc et consoeurs

Durée de vie

Jusqu'à présent, nous vous avons simplement expliqué qu'il existe plusieurs type de mémoire (registre, mémoire cache, RAM et disque dur) et que vos variables sont stockées soit dans des registres, soit dans la RAM.

Mais nos variables, tableaux et structures ne sont pas éternelles. Variables et données peuvent exister durant un certain temps et disparaitre ensuite. Elles occupent ainsi temporairement de la mémoire, qui peut être réutilisée lors de leur disparition.

Cette portion de mémoire, utilisée pour stocker une variable, un tableau, ou une structure, c'est ce qu'on appelle un objet. Ce n'est rien de moins qu'un gros bloc de mémoire, placé en RAM, qui va servir à stocker notre donnée. La différence entre variable, tableau, structure, etc, et un objet est assez simple : c'est la différence entre le contenant et le contenu.

Cet objet a quelques particularités. Il stocke une certaine donnée : int, char, tableau, structure, etc. Mais il a aussi une durée de vie. La durée de vie d'un objet détermine la portion de l'exécution du programme durant laquelle son espace mémoire est réservé pour une donnée ou une variable. Dit plus simplement, elle détermine la période durant laquelle un objet existe, à savoir quand est-il réservé et quand est-il libéré.

Durant cette durée de vie, notre objet est utilisé pour une variable ou une donnée en particulier. Une fois sa durée de vie terminée, la mémoire occupée par la variable / donnée est libérée et peut être utilisée pour autre chose, une autre variable, etc.

Il existe trois durées de vie pour les objets :

  • automatique ;
  • statique ;
  • dynamique.

Voyons ces trois durées de vie dans l'ordre, en commençant par la durée de vie automatique.

Automatique

La durée de vie automatique est celle qui correspond aux variables, structures, tableaux, etc ; déclarés dans un bloc d'instruction. Un objet de durée de vie automatique est crée lors de l'entrée dans le bloc d'instructions auquel il appartient et est détruit à la fin de l'exécution de celui-ci.

Il en va donc ainsi des variables locales aux fonctions de même que de leurs paramètres. Pour illustrer le principe, le code suivant est donc incorrect car p essaye d'accéder à un objet qui n'existe plus (la variable n a été détruite lors de la sortie de la fonction ptr*).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

static int * ptr(void)
{
    int n = 10;

    return  &n;
}

int main(void)
{
    int * p = ptr();
    *p = 20;

    return 0;
}

La zone mémoire qui contient des objets automatiques est appelée la pile . Au passage, un objet à durée de vie automatique est initialisé à chaque fois qu'il est crée. Par exemple, si vous initialisez des variables locales dans une fonction, elles le sont à chaque appel ; ainsi, la pile se vide et se remplit au fur et à mesure du programme.

Statique

Vient ensuite la durée de vie statique. Un objet de durée de vie statique est crée lors du lancement du programme et est détruit à la fin de celui-ci. C'est donc un objet qui existe de façon "permanente". Vous avez certainement du tilter en voyant le mot statique, et cela vous a surement rappelé quelque chose qu’on a vu dans les chapitres précédents.

Et bien vous avez raison : les variables déclarées avec le mot-clé static à l'intérieur d'un bloc d'instruction a bel et bien une durée de vie statique. Mais ce ne sont pas les seules : les variables déclarées en dehors de tout bloc d'instruction ont elles aussi une durée de vie statique.

Initialisation automatique

Au fait : ces données statiques sont initialisées une seule fois lors de l'exécution du programme. Si le programmeur ne les initialise pas lui-même, ces dernières le seront automatiquement. Pour les entiers, cette initialisation les met à 0. Pour les flottants, ils sont initialisés à 0.0. Quand aux pointeurs, ceux-ci sont initialisés à NULL.

Exemple

Ainsi, ce code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

#include <stdio.h>

static int plus(void)
{
    static int i;
    return i++;
}

int main(void)
{
    int i;

    for (i = plus(); i < 5; i = plus())
        printf("%d\n", i);

    return 0;
}

vous affichera :

1
2
3
4
5

0
1
2
3
4

Étant donné que la variable i de la fonction plus est automatiquement initialisée à zéro et qu'elle n'est détruite qu'à la fin du programme (elle conserve donc sa valeur lors des appels successifs à la fonction plus).

Dynamique

Un objet de durée de vie dynamique est crée et détruit à la demande du programmeur et est stocké dans le tas. L'intérêt de pouvoir gérer la durée de vie de différents objets est d'éviter les inconvénients propres aux objets de durée de vie automatique et statique.

Du côté des objets de durée de vie automatique, le problème est qu'ils sont détruits à la fin du bloc auquel ils appartiennent. Il est donc impossible de les utiliser en dehors du bloc de code dans lesquels on les a crées.

On peut citer l'exemple des variables locales d'une fonction. Pour ces dernières, ce n'est pas trop grave si on n'a qu'une seule donnée simple, vu qu'on peut le retourner : on peut retourner un int, un char, ou autre chose sans problème. Mais si on veut en retourner plusieurs, ou retourner une donnée plus complexe comme un tableau ou une structure, c'est impossible s'il sont déclarés avec une durée de vie automatique.

Le problème pourrait être contourné à l'aide d'objets de durée de vie statique, mais ce n'est pas très pratique car ces derniers sont persistants (ils ne sont détruits qu'à la fin du programme). De ce fait, si vous manipulez des données de tailles importantes, mais dont vous n'avez besoin que durant un certain temps, vous monopolisez de la mémoire pour rien.

Et c'est sans compter les cas où la taille de la donnée n'est pas connue à la compilation et dépend de paramètres extérieurs : difficile de réserver de la mémoire si on ne sait pas de quelle quantité on a besoin.

La solution consiste donc à pouvoir créer et détruire des objets sur demande. Pour permettre au programmeur d'en créer, le langage de programmation doit fournir de quoi réserver de la mémoire. Cette réservation est plus ou moins bien cachée suivant le langage. Et en C, rien n'est masqué : vous pouvez vous même allouer (réserver) de la mémoire à la main et la libérer. Bien sûr, cela n'est pas sans conséquences : si vous oubliez de libérer la mémoire quand vous n'en avez plus besoin, vous allez rapidement la remplir, ce qui risque d'entrainer de sérieux problèmes.

Pour éviter de laisser la gestion de la mémoire au programmeur, certains langages sont capables de libérer eux-même la mémoire : ils incorporent ce qu'on appelle un garbage collector, un petit morceau de programme qui se charge de détecter les blocs de mémoire réutilisables et de les libérer à votre place. C'est le cas en Java par exemple, mais pas en C. Aussi, vous devez apprendre à gérer la mémoire à la main, en utilisant des fonctions fournies par le langage C.

Malloc et consoeurs

Le C fourni diverses fonctions qui permettent de réserver un bloc de mémoire afin de pouvoir l'utiliser comme bon nous semble. Ces fonctions d'allocation dynamique sont au nombre de quatre, et elles sont définies dans l'en-tête <stdlib.h>. Pensez donc bien à l'inclure dans vos programmes en utilisant la déclaration de fichier d’en-tête suivante :

1

#include <stdlib.h>

Maintenant, il faut voir comment nous allons devoir utiliser ces fonctions d'allocation mémoire.

Schéma à suivre

Lorsque l'on souhaite faire une allocation dynamique, il y a un certain nombre d'étapes à suivre. Certaines peuvent sembler lourdes et inutiles, mais c'est à l'ardeur et la rigueur qu'il code que l'on reconnaît un bon programmeur. Ce schéma, que je vous invite fortement à suivre, est le suivant :

  • Déclarer un pointeur : le pointeur va servir à contenir l'adresse de l'élément que l'on va allouer.
  • Appeler la fonction d'allocation : cette fonction va s'occuper d'allouer un espace mémoire en fonction des paramètres qu'on lui passe.
  • Vérifier le retour de la fonction : si la fonction a réussi à allouer un espace mémoire, elle retourne un pointeur sur cet espace. Cependant il se peut que dans certains cas (pas assez de mémoire, demande d'allocation trop importante, etc), la fonction échoue, et dans ce cas elle retourne NULL. Il faut donc tester notre pointeur : s'il vaut NULL, l'allocation a échoué, et on quitte le programme.
  • Une fois que l'on ne souhaite plus utiliser l'espace mémoire, on libère la zone allouée : c'est important, sinon on retombe sur le risque vu précédemment à savoir une saturation du tas.

Examinons à présent ces quatre fonctions.

malloc - Allouer de la mémoire

La première de ces fonctions est malloc. C'est la plus simple de toute. Cette fonction va simplement réserver un gros bloc de mémoire pour que l'utilisateur puisse l'utiliser comme il le souhaite. Petite remarque : le bloc de mémoire réservé par malloc n'est pas modifié ni initialisé. Celui peut donc contenir n’importe quoi.

Arguments

Évidemment, si on veut réserver de la mémoire, on doit préciser quelle est la taille du bloc de mémoire voulu. Pour cela, il suffit de préciser la taille du bloc en argument de malloc, et la fonction réservera celui-ci. L'argument de malloc sert donc à préciser sa taille.

On pourrait se dire naïvement que cette taille est un nombre entier strictement positif et que le type de cet argument est un int ou un unsigned int. Et bien pas du tout ! malloc a besoin que la taille qu'on lui envoie soit de type size_t. Pourquoi ? Parce que size_t est le type utilisé par l'opérateur sizeof et est par conséquent le type naturel pour représenter la taille des objets

sizeof

Nous venons de le dire, malloc attend une certaine taille en argument. Autant prévenir tout de suite : cette taille est exprimée en bytes. Ainsi, si j'envoie 2000 en argument à malloc, celle-ci réservera 2000 bytes de mémoire. Seulement voilà, comment faire si je veux stocker 2000 int, ou 2000 double ? En effet, si je ne connais pas la taille du type de données à allouer avec malloc, il me sera impossible de connaitre la taille en octets de la mémoire à réserver avec malloc. Et cette la taille varie suivant l'ordinateur, le compilateur, le système d’exploitation, et l'âge du capitaine.

Heureusement, on a déjà vu qu'il existe un opérateur qui permet de récupérer la taille d'un type : c'est sizeof. Grâce à cet opérateur, il est possible de connaître le nombre de bytes occupé par n'importe quel type (sauf void). On peut ainsi allouer des zones mémoires de la bonne taille, grâce à cet opérateur. Ainsi, si on veut allouer $n$ éléments, il suffit de passer n * sizeof(Type d'un element) en argument de malloc.

Retour de malloc

Cette fonction va retourner un pointeur qui stocke l'adresse du début du bloc réservé. Ce pointeur est un pointeur sur void. Il faut dire que lorsque l'on réserve un bloc de mémoire, celui-ci peut être utilisé pour un tas de choses : des int, des char, des float, ou un mélange de données hétérogènes. Bref, malloc pouvant être utilisé pour tout et n'importe quoi, elle doit fatalement retourner un pointeur qui peut pointer sur tout et n'importe quoi. En clair, un pointeur sur void.

Avec ce qu'on a dit au-dessus, on peut donc déduire facilement le prototype de notre fonction malloc. Le voici :

1

void * malloc(size_t size);

Exemple

Maintenant, exerçons-nous en suivant le schéma introduit ci-dessus. On doit créer un pointeur et appeler malloc.

1

int * ptr = malloc(sizeof(int));

Je déclare ici un pointeur sur un int, qui va servir à stocker l'adresse de la zone mémoire réservée par malloc. La règle est simple : pour allouer dynamiquement un objet de type T, il faut créer un pointeur de type T.

Pour connaître la taille de l'objet que je veux allouer (ici un int), j'utilise sizeof. Grâce à cet opérateur, on peut allouer des objets de taille différente très facilement.

Maintenant, il faut pense à bien vérifier que l'adresse est valide. En effet, même s'il y a peu de chance que l'allocation échoue, le risque est toujours là, et pour s'en protéger il n'y a qu'une solution : vérifier que le pointeur est bien valide. Il suffit de tester si le pointeur est différent de NULL et d'agir en conséquence. Essayons avec notre code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

int main(void)
{
    int * ptr = malloc(sizeof(int));

/* si malloc a retourné NULL... */
    if (ptr == NULL)
    {
    /* ...on affiche l'erreur grâce à perror */
        perror("Malloc");
    }

/* sinon c'est que l'allocation a réussi et on peut continuer */
    puts("Malloc a reussi !");
    return 0;
}

Ce code présente une nouveauté : si jamais l'allocation a échoué et que le pointeur vaille NULL, on appelle la fonction perror. Cette fonction décrit la dernière erreur rencontrée durant un appel système ou une fonction de bibliothèque. Ainsi, si malloc échoue, perror affichera la raison de l'échec. Si au contraire l'allocation s'est bien passée, le programme affichera : « Malloc a reussi ! ».

Mais que doit-on faire si malloc échoue hormis afficher l'erreur ? En général, on préfère quitter le programme. Pour cela, on utilise la fonction exit, également définie dans <stdlib.h>, que voici :

1

void exit(int status);

L'unique argument de cette fonction est status. Il peut être une des deux valeurs suivantes (définies aussi dans <stdlib.h>) :

  • EXIT_SUCCESS : À utiliser pour quitter le programme avec succès.
  • EXIT_FAILURE : À utiliser pour quitter le programme en cas d'échec.

Sachez néanmoins que dans le cas général, malloc a très peu de chance d'échouer. Ceci dit, on est jamais trop prudent, donc continuez de vérifier le retour de malloc et agissez en conséquence.

Maintenant, on peut utiliser l'espace que l'on a alloué comme l'on veut. Mais une fois qu'on en a plus besoin, comment le libérer ?

free - Libérer un espace alloué dynamiquement

Comme je l'ai déjà dit, si on réserve de la mémoire avec l'allocation dynamique, c'est de façon plus ou moins temporaire. Au bout d'un certain temps, la mémoire qu'on a réservée ne nous sert plus. On doit alors la libérer pour la rendre réutilisable. Pour libérer la mémoire allouée, on a besoin d'une fonction dédiée à ça : free. Cette fonction libère la mémoire déjà réservée par un malloc, un calloc ou un realloc.

Surtout, retenez bien une chose très importante : à chaque appel à malloc doit correspondre un appel à free. Autrement dit : un malloc == un free.

Voici son prototype :

1

void free(void * ptr);

On remarque que la fonction free ne retourne rien, et c'est tout à fait normal (que voulez-vous retourner ?).

Argument

L'unique paramètre qu'elle prend est l'adresse de la zone mémoire à libérer. Ce paramètre est donc un pointeur. Et bien sur, vu qu'on ne sait pas ce pour quoi le bloc de mémoire utilisé a été utilisé, ce pointeur est un pointeur sur void, qui ne précise pas le type des données stockées dans le bloc de mémoire à libérer.

Petite précision : si on passe un pointeur qui vaut NULL en argument de free, celle-ci ne fait rien. En revanche, quand on vous passez à free un pointeur sur un bloc de mémoire qui a déjà libéré, le comportement est indéterminé. Généralement, libérer plusieurs fois de la mémoire correspond à une erreur de programmation, mais il faut savoir que cela arrive parfois, et que savoir gérer ce genre de cas correctement est impératif. Pour éviter ce genre de problème, il y a une solution simple : une fois que vous avez effectué un free sur un pointeur, mettez-celui-ci à NULL. Comme ça, si jamais vous avez mal programmé et que vous libérez plusieurs fois un même pointeur, vous n'aurez pas de problèmes, vu que le pointeur à libérer sera un pointeur NULL : free ne fera rien.

calloc - Allocation et mise à zéro

La fonction malloc n'est pas la seule fonction qui permet d'allouer de la mémoire. Elle a des petites sœurs : calloc et realloc. Voyons un peu la fonction calloc. Cette fonction va simplement réserver un gros bloc de mémoire pour que l'utilisateur puisse l'utiliser comme il le souhaite. Son rôle est donc similaire à celui de malloc, à un détail près : malloc ne faisait rien sur le bloc de mémoire réservé et ne initialisait pas ; calloc fait exactement le contraire : elle va initialiser le bloc de mémoire réservé avec des zéros.

Cependant, il est déconseillé de l'utiliser pour initialiser des données contentant des flottants ou des pointeurs, car selon les architectures, il se peut que mettre la zone mémoire à 0 ne mette pas des flottants ou des pointeurs à 0. Ceci est assez compliqué à expliquer sans rentrer dans les détails, mais nous devions néanmoins vous avertir.

Voici le prototype de calloc :

1

void * calloc(size_t n, size_t size);

Arguments

La fonction calloc attend deux paramètres. Le premier paramètre, noté n, n'est rien d'autre que le nombre d'éléments à allouer. Le second paramètre, noté size dans le prototype du dessus, indique la taille d'un élément. Ces deux paramètres sont de type size_t.

Retour

Comme pour malloc, calloc va renvoyer un pointeur qui contient l'adresse du début du bloc réservé. Il va de soit que ce pointeur est un pointeur sur void, exactement pour les mêmes raisons que malloc.

realloc - Réallouer à volonté

Passons maintenant à la cadette de la fratrie des fonctions d'allocation. Il s'agit de la plantureuse realloc. Celle-ci sert à agrandir ou rétrécir un bloc de mémoire qui a été préalablement réservé par malloc ou calloc.

Voici son prototype :

1

void * realloc(void * ptr, size_t size);

Arguments

Cette fonction prend donc deux arguments. Le premier est un pointeur qui pointe vers la zone de mémoire dont on veut changer la taille. Il est important de noter que le pointeur passé en argument doit obligatoirement avoir été alloué avec malloc, calloc ou realloc, sinon le comportement est indéterminé. Autre détail : si realloc reçoit un pointeur nul en argument, elle se comporte comme malloc.

Le deuxième paramètre n'est rien d'autre que la nouvelle taille qu'on veut attribuer à ce bloc de mémoire. Petit détail : si realloc reçoit une taille nulle en argument, elle se comporte comme free.

Retour

Cette fonction retourne un pointeur qui pointe sur le bloc de mémoire. Il faut dire que le pointeur renvoyé par realloc n'est pas forcément le même que celui passé en paramètre : rien n’empêche realloc d'allouer un nouveau bloc de mémoire, déplacer les données de l'ancien vers le nouveau et libérer l'ancien. Il faut donc que realloc renvoie un pointeur vers ce nouveau bloc. Ce pointeur est un pointeur sur void, pour les mêmes raisons que malloc et calloc.

Bien entendu, comme avec les deux autres fonctions, il faut vérifier que la fonction a réussie. Mais il y a également autre chose à prendre en compte, que nous allons illustrer avec un exemple.

1
2
3

int * ptr = malloc(10 * sizeof(int));

ptr = realloc(ptr, 30 * sizeof(int));

Dans notre code d'exemple, on ré-alloue directement la zone mémoire, ce qui a première vue est logique et normal. Mais si jamais la ré-allocation venait à échouer on se retrouverait avec un pointeur invalide (car realloc retournera NULL), mais aussi avec une zone mémoire qu'on ne pourra pas libérer, car on a perdu son adresse : c'est une fuite mémoire. Le seul moyen d'éviter cet accident est de passer par un pointeur intermédiaire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

int * ptr = malloc(10 * sizeof(int));
int * temp;

/* On veut agrandir la zone mémoire en passant de 10 int à 30 int. */
temp = realloc(ptr, 30 * sizeof(int));
if (temp != NULL)
{
/* Si l'allocation a marché, alors on peut faire pointer ptr sur la nouvelle zone mémoire ... */
    ptr = temp;
}
else
{
/* ... sinon on libère ptr, on le met à NULL, et on écrit l'erreur. */
    free(ptr);
    ptr = NULL;
    perror("Realloc");
}

/* On oublie pas de libérer à la fin. */
free(ptr);
ptr = NULL;

Précisions

On a dit plus haut qu'on pouvait agrandir ou rétrécir un espace avec realloc. Il faut savoir que dans les deux cas, les valeurs présentes dans le bloc de mémoire avant la ré-allocation seront conservées. Dans le cas d'un agrandissement, elles le sont intégralement. Et dans le cas d'un rétrécissement, on ne conserve que les valeurs des cases restantes.


Les flux

Avez-vous remarqué que tout ce que nous avons fait jusqu'à maintenant n'était que temporaire et disparaissait dès qu'on quittait le programme ou qu'on éteignait l'ordinateur ? Il était impossible de stocker des informations de manières permanentes. Il est grand temps de réparer cette injustice et c'est le but de ce chapitre : vous apprendre à manipuler les fichiers en C. Il sera désormais possible de stocker des scores à un jeu vidéo ou les numéros de téléphone d'un carnet d'adresse par exemple.

  • Un peu de théorie
  • Ouverture et fermeture de flux
  • Lecture d'un flux
  • Écriture dans un flux
  • D'autres fonctions

Un peu de théorie

Préambule

En informatique, un fichier est une collection, un ensemble d'informations numériques stockées sur un support de mémoire, comme un disque dur par exemple. Cette collection d'informations est réunie sous un même nom, et manipulées comme une unité.

Afin de faciliter la localisation des fichiers, ces derniers sont classés dans des systèmes de fichier. Un système de fichiers est une façon de stocker les informations et de les organiser dans des fichiers. Une telle gestion des fichiers permet de traiter, de conserver des quantités importantes de données ainsi que de les partager entre plusieurs programmes informatiques. Un système de fichiers permet à l'utilisateur de localiser des données à partir d'un chemin d'accès.

Le nom du fichier sert à décrire le contenu. Ce nom est souvent construit de la manière suivante : suffixe.extension. Une extension renseigne sur la nature des informations et le logiciel utilisé pour les manipuler. L'extension d'un fichier est facultative. Par exemple, sous les systèmes de type UNIX ou GNU/Linux, il n'est pas rare de trouver des noms de fichiers sans extension. Chaque fichier comporte un certain nombre de métadonnées, c'est à dire des informations concernant des informations sur le fichier en question telles que :

  • sa longueur ;
  • son auteur ;
  • ses droits d'accès (les personnes autorisées à le manipuler) ;
  • sa date de la dernière modification ;

Le plus important dans un fichier est les informations qu'il contient. Le format d'un fichier est la convention selon laquelle les informations ainsi que les métadonnées sont écrites dans le fichier. Selon la nature et le format du contenu, les fichiers peuvent être qualifiés d'exécutables, de textes, de documents, d'images, d'audio, de vidéos etc.

Voici quelques formats courants :

  • Textes et documents : .txt ; .asc ; .doc ; .htm ; .html ; .msg ; .xls ; .odt
  • Images : .gif ; .jpg ; .bmp ; .png ; .eps ; .tif
  • Audio : .mp3 ; .wav ; .au ; .ra ; .ram
  • Vidéo : .avi ; .mpg ; .mov
  • Exécutables : .exe ; .com ; .bat
  • Compressés : .arc ; .zip ; .z ; .arj ; .tar ; .sit ; .gz ; .bz2 ; .rar ; .xz

Seulement, certains formats de fichiers peuvent être propriétaire et donc difficilement exploitables. Un format propriétaire est un format de fichier dont les spécifications sont contrôlées par une entité privée. Un tel format n'est pas libre d'utilisation.

Dernier point : le chemin d'accès d'un fichier. Nous en avons déjà parlé brièvement plus haut, attardons-nous maintenant sur les petites particularité de ce dernier. Le chemin d'accès d'un fichier (ou d'un répertoire) est une chaîne de caractères décrivant la position de celui-ci dans le système de fichiers. Nous n'avons pas encore parler de la racine des répertoires. Sans entrer dans les détails, le répertoire racine est la base des répertoires de tout fichiers. Ensuite, c'est le séparateur de répertoire qui entre en jeu; c'est lui qui permet de localiser un fichier situé dans plusieurs sous-dossiers. Petit résumé :

OS | Répertoire racine | Séparateur de répertoire
UNIX-like | / | /
Windows | lecteur:\ | \

Il est possible d'utiliser sous Windows le même séparateur de répertoire que les systèmes dérivés d'UNIX, à savoir : '/'. Cette pratique a pour effet de facilement rendre portable vos programmes. En effet, si l'on prévoit les grands deux séparateur de répertoire, la création d'une fonction à part et/ou l'utilisation du préprocesseur devient indispensable. Or là, tout est plus simple. Il existe deux moyens pour indiquer le chemin d'un fichier :

  • Le chemin absolu, vous le connaissez bien, indique que le fichier va être cherchée en partant de la racine du système de fichiers. Par exemple :
1
2
3
4

C:/Users/paraze/prog/fichiers/test.txt
/home/paraze/prog/tuto/main.c
/home/paraze/prog/tuto/fichier.txt
/home/paraze/prog/tuto/ressources/scores.scr
  • Le chemin relatif qu'en à lui, est identique au précédant à l’exception près qu'au lieu de partir de la racine, on commence au répertoire courant. En pratique, le répertoire courant est généralement celui où ce trouve l’exécutable.
1
2
3

fichier.txt
maximal_crazy.mp3
ressources/scores.scr

Chacun possède ses défauts et qualités. Le chemin absolu est le plus sûr et le moins flexible. Le chemin relatif est bref, flexible, mais n'est pas sans danger.

En langage C

La bibliothèque standard du C fournie plusieurs fonctions qui permettent de réaliser des entrées/sorties de manière portable. Ici, c'est <stdio.h> qui travaille puisque, je vous le rappelle, ce fichiers d'en-tête signifie standard input-output, soit entrée-sortie standard.

Les entrées/sorties en langage C se font par des flux (stream en anglais), qui représentent des objets externes au programme, appelés fichiers. Selon la manière dont on veut réaliser les opérations d'entrées/sorties sur le fichier, on distingue deux grandes catégories de flux : les flux de textes et les flux binaires.

En C, un flux est représenté par un pointeur sur une une structure de type FILE. Cette structure contient des informations concernant un fichier. Voici les trois flux pré-définis par <stdio.h> :

  • stderr : Flux d'erreurs standard, par défaut, l'écran.
  • stdout : Flux de sortie standard, par défaut, l'écran.
  • stdin : Flux d'entrée standard, par défaut, le clavier.

Ouverture et fermeture de flux

fopen - Ouverture d'un flux

L'ouverture d'un flux est nécessaire à la manipulation de ce dernier, et pour ce faire, la fonction fopen est tout indiquée.

Explication

Voici son prototype :

1

FILE * fopen(const char * path, const char * mode);

La chaîne de caractères constante path signifie chemin en français. Il faut donc passer en premier paramètre une chaîne de caractère contenant le chemin (absolu ou relatif, comme vous voulez). Petit rappel : certaines fonction du C comme printf considère le caractère '\' comme un caractère spécial (ce caractère est en fait utilisé pour la manipulation de caractères d'échappements, comme '\n' ou '\t'). Donc, pour ouvrir un fichier situé dans un sous-dossier, vous devrez doubler de l'antislash ("\\") afin de bien faire comprendre au compilateur qu'on souhaite utiliser ce symbole.

La seconde variable, nommée mode, indique le mode d'ouverture du fichier : veut-on simplement lire un fichier ou bien l'éditer ? Veut-on supprimer tout son contenu avant ? Voici différents modes d'ouvertures disponibles :

  • "r" : lecture à partir du début du fichier. Le fichier indiqué par le premier argument doit obligatoirement exister, sinon la fonction échoue.
  • "w" : écriture à partir du début du fichier. Si le fichier n'existe pas, il sera créé. Si il existe, son contenu est effacé.
  • "a" : écriture à partir de la fin du fichier. Si le fichier n'existe pas, il sera créé.
  • "r+" : lecture et écriture à partir du début du fichier. Le fichier indiqué par le premier argument doit obligatoirement exister, sinon la fonction échoue.
  • "w+" : lecture et écriture à partir du début du fichier. Si le fichier n'existe pas, il sera créé. Si il existe, son contenu est effacé.
  • "a+" : lecture et écriture à partir de la fin du fichier. Si le fichier n'existe pas, il sera créé.

Ces six modes sont très important, et à savoir par cœur ; ils permettent d'ouvrir de simples flux de textes. Sachez qu'il en existe encore six autres. En effet, nous avons déjà brièvement parlé des flux binaires, ceux-ci sont manipulables grâce à des modes dit non formaté. Nous aurons l’occasion d'en reparler plus loin dans le chapitre.

La fonction retourne un pointeur sur le flux demandé si l'ouverture a réussi ou bien NULL si elle a échoué. Comme pour les fonctions d'allocation dynamique, il faudra penser à vérifier le retour de fopen et agir en conséquence.

Application

Je vous mets au défi d'ouvrir un fichier quelconque localisé dans le répertoire courant en lecture simple ! Ces codes, d'apparences corrects, sont pourtant de véritables bombes à retardements. En effet, il suffit que l'ouverture échoue (ce qui arrive souvent, si le fichier n'est pas présent ou si vous avez fait une faute de frappe) pour que votre programme crash. Nous parlions tout à l'heure de la très utile valeur de retour de la fonction fopen. Comme un code vaut 42 mots, voici un exemple possible montrant la vérification du retour de cette fonction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15

#include <stdio.h>

int main(void)
{
    FILE * fichier = fopen("fichier.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    /* Opérations sur le fichier ... */

    return 0;
}

Faut-il là aussi quitter le programme si fopen échoue ? C'est à vous de décider, mais en général l'échec de fopen est dû au fait que soit le fichier n'existe pas, soit le chemin est incorrect, donc mieux vaut retenter en proposant à l'utilisateur de modifier le chemin ou de créer le fichier. Fermer le programme est une façon un peu trop brutale de dire à l'utilisateur qu'il a fait n'importe quoi.

fclose - Fermer un fichier

Vous savez ouvrir un flux de texte et même vérifier son ouverture, mais le plus important, et le plus simple en passant, reste à voir. Vous vous souvenez du chapitre sur l'allocation dynamique, avec malloc, free et tout ce qui va avec ? Si vous êtes observateur, vous avez sûrement remarqué que l'on répète le même schéma :

  • Déclarer un pointeur sur FILE : déclarer un pointeur ;
  • Appeler la fonction *fopen* : appeler la fonction d'allocation
  • Vérifier le retour de la fonction : idem.

Que manque t-il ? La libération de la zone de mémoire. Cette fonction la voici :

1

int fclose (FILE * stream);

La fonction fclose libère le flux nommé stream. Il est impossible d'y accéder par la suite, sous peine d'un comportement indéterminé. Elle renvoie 0 en cas de réussite, et EOF si la fonction ne réussit pas à fermer correctement le fichier. La constante EOF signifie End Of File et est la plupart du temps retournée lorsque lit un fichier et que l'on arrive à la fin de celui-ci. Cependant, il arrive aussi qu'elle représente une erreur de traitement (comme dans le cas ici présent). Il est rare de voir cette fonction planter sans raison apparente, évitez juste de lui passer en paramètre un pointeur sur FILE déjà libéré ou non-alloué.

Je vous donne le code final commenté :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>

int main(void)
{
    FILE * fichier = fopen("fichier.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    /* Opérations sur le fichier ... */

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Ouvrir plusieurs fichiers à la fois

Certains se sont peut-être déjà demandés s'il est possible d'ouvrir plusieurs flux en même temps. La réponse est oui. Le code suivant vous montre un exemple où l'on ouvre 5 fichiers à la fois à l'aide d'un tableau de FILE*. Inutile de préciser que les règles sont les mêmes que précédemment.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

#include <stdio.h>

#define TAILLE_MAX 20
#define NB_FICHIER 5

int main(void)
{
    FILE * fichier[NB_FICHIER];
    char s[TAILLE_MAX];
    size_t i;

    for(i = 0; i < NB_FICHIER; ++i)
    {
        sprintf(s, "fichier%d.txt", i + 1);

        fichier[i] = fopen(s, "r");
        if(fichier[i] == NULL)
        {
            printf("Erreur lors de l'ouverture du fichier %d\n", i + 1);
            perror("fopen");
            /* ... */
        }
    }

    /* ... */

    for(i = 0; i < NB_FICHIER; ++i)
    {
        fclose(fichier[i]);
    }

    return 0;
}

Y'a t-il une limite au nombre de flux ouvrables en même temps ? Il y a effectivement une limite, mais celle-ci peut être variable. Pour nous aider, nous avons deux constantes de préprocesseur définies dans <stdio.h> que voici :

  • FOPEN_MAX : elle indique le nombre maximum ouvrables en même temps par un programme. La norme garantit qu'elle vaut au minimum 8 (en comptant les trois flux standards) :

The value of the macro FOPEN_MAX shall be at least eight, including the three standard text streams -- C89 - 4.9.3 Files

  • FILENAME_MAX : elle indique la taille maximum du nom d'un fichier pour qu'on soit sûr qu'il soit ouvert. Sous certaines implémentations, une taille supérieure marchera, mais dans le doute, essayez de limiter la taille du nom de vos fichiers.

Ces deux constantes vous serons utiles pour faire un code portable et sans risque de comportement indéterminé quand vous serez amenés à manipuler de nombreux fichiers.

Lecture d'un flux

fgetc - Lecture d'un caractère

Commençons doucement avec la fonction fgetc qui lit un caractère depuis un flux. Voici son prototype :

1

int fgetc(FILE * stream);

Avant de continuer, il faut savoir que lire un fichier réclame quelque chose d'important : un indicateur de position. Il faut savoir que lorsque l'on parcourt un flux, un indicateur de position est utilisé pour indiquer où on en est dans le flux. Imaginez-vous un curseur virtuel que l'on avance d'une case à chaque caractère lu par fgetc, et vous aurez compris le principe.

fgetc renvoie donc le caractère lu si tout s'est bien passé ou la macro EOF si l'indicateur de position est tout à la fin du fichier (ou en cas d'erreur).

Afin d'effectuer des opération sur un fichier, il faut toujours réfléchir sur le meilleur mode d'ouverture à choisir. Nous souhaitons lire un fichier, ce qui nous laisse que 4 possibilités :

  • "w+" : supprime le contenu du fichier précédemment ouvert ;
  • "a+" : place l'indicateur de position est à la fin de celui-ci ;
  • "r+" : ouvre le fichier en lecture et en écriture, l'indicateur de position est placé au début ;
  • "r" : ouvre le fichier en lecture, l'indicateur de position est placé au début ;

Deux modes d'ouvertures sembles correspondre : le simple "r" et "r+". Les deux sont corrects pour notre utilisation, je recommande cependant l'utilisation du premier, qui nous assure que le fichier ne sera pas modifié ; c'est une mesure de protection.

Voici donc un code lisant la première lettre d'un fichier nommé salutation.txt contenant la phrase Salut :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

#include <stdio.h>

int main(void)
{
    FILE * fichier;
    int c;

    fichier = fopen("salutation.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    c = fgetc(fichier);
    putchar(c);

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Voilà ce que cela donne :

1

S

Cette fonction peut lire un caractère de n'importe quel flux, nous pouvons donc faire ceci pour récupérer un caractère depuis le flux d'entrée standard :

1

c = fgetc(stdin);

L'appel de la fonction getchar est strictement identique à l'utilisation de fgetc sur stdin.

Néanmoins, la grande limite de fgetc est le fait qu'elle ne lis qu'un seul caractère. Afin de lire tout le contenu d'un fichier, l'utilisation d'une boucle est tout indiqué. Rappelez-vous, EOF est retourné en cas de fin de fichier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

int main(void)
{
    FILE * fichier;
    int c;

    fichier = fopen("fichier.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    /* Tant que le caractère actuel n'est pas la fin du fichier */
    while((c = fgetc(fichier)) != EOF)
    {
        putchar(c);
    }

    fclose(fichier);
    fichier = NULL;
    return 0;
}

1
2

Salut !
Je m'appelle paraze.

fgets - Lecture d'une chaîne de caractères

Voici le prototype de la fonction fgets :

1

char * fgets(char * s, int size, FILE * stream);

Cette fonction est un peu plus compliquée que la précédente. Elle lit une ligne de caractère depuis le flux stream. Plus précisément, elle lit le flux caractère par caractère (en comptant le '\0') puis s'arrête lorsqu'elle a déplacé l'indicateur de plus de size - 1 (le nombre voulu de caractères). La fonction fgets possède encore deux autres conditions d'arrêt : lorsqu'elle rencontre un retour à la ligne ou lorsqu'elle lit EOF. Le pointeur s contient la chaîne lue par fgets.

La fonction retourne ... le pointeur s. À l'instar de la fonction fgetc, le retour de cette fonction permet de savoir s'il y a eu un problème de lecture ou si l'on est à la fin du fichier. Ces deux cas conduiraient simplement au retour de la valeur NULL. Voici un code possible utilisant fgets :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#include <stdio.h>

#define TAILLE_MAX 100

int main(void)
{
    FILE * fichier;
    char chaine[TAILLE_MAX];

    fichier = fopen("fichier.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    fgets(chaine, TAILLE_MAX, fichier);
    puts(chaine);

    fclose(fichier);
    fichier = NULL;
    return 0;
}

1

Salut !

Remarquez l'utilisation de la constante TAILLE_MAX. L'utilisation d'une telle macro est très courante lorsque l'on manipule des flux, car elle permet de créer une chaîne de caractère aussi grande que le nombre de caractère maximum que l'on va lire sur une ligne. Comme la première ligne de mon fichier fait moins que 100 caractères, elle est entièrement lue. Essayez de faire varier cette constante (c'est là que l'on voit bien son utilité : on évite de modifier à deux reprises cette valeur) et vous verrez, la chaîne lue est tronquée ; de plus, aucun débordement de mémoire n'est fait. C'est une sorte de sécurité.

Vous avez toutes les clés en main, lire entièrement le fichier ne devrait pas être trop difficile :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>

#define TAILLE_MAX 100

int main(void)
{
    FILE * fichier;
    char chaine[TAILLE_MAX];

    fichier = fopen("fichier.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    /* Tant que la fin du fichier n'est pas rencontrée */
    while((fgets(chaine, TAILLE_MAX, fichier)) != NULL)
    {
        puts(chaine);
    }

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Puisque fgets déplace l'indicateur de conversion d'une ligne lors d'un appel, la future lecture (voyez par là le futur appel) débutera à partir du début de la ligne suivante.

Dernier point concernant cette fonction : le caractère '\n' est inclut dans la chaîne précédemment lue. C'est pour cela que notre code ci-dessus affichait correctement le fichier, avec des retours à la ligne. Cela peut vite devenir un problème, si l'on essaie de comparer la chaîne lue avec une autre chaîne de caractères quelconque par exemple. Le '\n' sera pris en compte lors de la comparaison et risque d'altérer le comportement de notre fonction d'analyse. Comment palier à ce problème ? Il faut chercher le caractère '\n' et le remplacer par '\0'. Pourquoi ne pas créer une fonction tant qu'à faire ? Cela fera un très bon exercice.

La grande famille de scanf

Concluons cette sous partie sur l'une des sœurs de scanf : fscanf. Cette dernière aura le même fonctionnement que scanf, à l’exception près que l'on doit lui indiquer le flux à lire. La fonction de base lis le flux stdin ; dans notre cas, avec fscanf, ce sera le fichier à analyser. Voici son prototype :

1

int fscanf(FILE * stream, const char * format, ...);

Voici un exemple concret d'utilisation, en supposant que j'ai sous la main un fichier nommé scores.txt contenant :

1
2
3
4
5

paraze 100
Pouet_forever 96
SofEvans 95
informaticienzero 42
tib92 37

Afin de récupérer le noms des joueurs ainsi que leurs points, voici comment je procéderai :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>

int main(void)
{
    FILE * fichier;
    char nom[5][50];
    int score[5];
    int i;

    fichier = fopen("scores.txt", "r");
    if(fichier == NULL)
    {
        perror("fopen");
       /* ... */
    }

    for(i = 0; i < 5; i++)
    {
        fscanf(fichier, "%s %d", nom[i], &score[i]);
        printf("%s : %d\n", nom[i], score[i]);
    }

    fclose(fichier);
    fichier = NULL;
    return 0;
}

La fonction fscanf lit le symbole "%s", puis cherche la première chaîne de caractère présente à partir de l'indicateur de position, soit "paraze". Ce curseur justement, est déplacé de 6 caractères (la longueur de la chaîne). Ensuite, elle cherche un nombre (présence de l'indicateur de conversion "%d"), la valeur 100 est sélectionnée. L'appel de cette fonction est terminée, et nous avons finalement récupéré et stocké une chaîne de caractère et un nombre entier. Elle procède ainsi pour chaque ligne.

Coupler les structures avec fscanf est une idée très judicieuse. En effet, imaginez un fichier construit avec la structure suivante :

1

<pseudo> <commentaire> <nb_partie> <score>

Se balader avec 4 tableaux serait un peu lourd, alors pourquoi ne pas utiliser la puissance des structures ?

1
2
3
4
5
6
7
8
9

#define TAILLE_MAX 100

typedef struct
{
    char pseudo[TAILLE_MAX];
    char com[TAILLE_MAX];
    unsigned nb_parties;
    unsigned score;
} Info;

Serez-vous capable de remplir les champs de cette structure à partir d'un fichier ?

Écriture dans un flux

Vous vous en doutiez sûrement, après la découverte de trois fonctions permettant de lire un flux, nous allons travailler sur des fonctions d'écriture. Vous pourrez facilement observer la similitude entre ces fonctions et celles vues à l'instant.

fputc - Écriture d'un caractère

La fonction fputc écrit un et seulement un seul caractère dans fichier :

1

int fputc (int c, FILE * stream);

Vous l'aurez bien compris, cette fonction écrit le caractère c de type int dans le flux stream. Elle renvoie la plupart du temps le caractère qui vient d'être écrit ; en cas d'erreur, EOF est retourné.

Nous avions déjà débattu sur le mode d'ouverture à choisir, et je vous propose de recommencer. Afin d'écrire dans un fichier vierge, je vous recommande le mode "w" et "w+", ces deux-là créent le fichier s'il n'existe pas, efface son contenu et bien sûr permettent l'écriture dans ce dernier. Les modes "a" et "a+" sont tout aussi intéressant : si le fichier est inexistant, il sera créé ; de plus, son contenu n'est pas effacé et l'indicateur de position est placé à la fin du fichier (et donc au début dans le cas d'un fichier vide). Le dernier mode d'ouverture acceptant l'écriture d'un flux est "r+", le curseur virtuel est je vous le rappelle placé au début du fichier, rien est effacé mais le fichier doit obligatoirement être présent, sous peine d’échec de la fonction. Dans cet exemple, le mode choisi sera "w" puisque l'on a pas besoin de lire le fichier, ni d'ajouter du texte à un endroit précis.

Le code suivant écrit le texte "Salut" dans un fichier nommé test.txt :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

#include <stdio.h>

int main(void)
{
    FILE * fichier;

    fichier = fopen("test.txt", "w");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    /* Écriture de "Salut" */
    fputc('S', fichier);
    fputc('a', fichier);
    fputc('l', fichier);
    fputc('u', fichier);
    fputc('t', fichier);

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Vous exécutez le programme, et rien ne se passe ... en apparence. Effectivement, si vous ouvrez le fichier test.txt avec un éditeur de texte, vous verrez que la chaîne "Salut" s'est bel et bien créée.

Comme vous pouvez le constater, la fonction fputc n'est pas très pratique pour écrire de longue chaîne de caractères. On pourrait imaginer l'utilisation d'une boucle comme ceci pour pallier à ce problème :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

const char * s = "Salut";
const size_t taille = strlen(s);
size_t i;

/* Je passe l'ouverture du fichier et la vérification */

for(i = 0; i < taille; ++i)
{
    fputc(s[i], fichier);
}

C'est déjà mieux, bien que peu pratique. L'idéal serait d'avoir une fonction qui puisse écrire une chaîne de caractères entière d'un coup. Heureusement cette fonction existe.

fputs - Ecriture d'une chaîne de caractères

Voici le prototype de fputs, semblable à puts, sa sœur que nous avions rencontré au début de ce tutoriel :

1

int fputs (const char * s, FILE * stream);

La fonction écrit la chaîne s dans le flux stream et retourne un nombre non négatif si elle réussit. Dans le cas contraire, elle retourne EOF. Le code suivant fait la même chose que celui juste avant, mais en utilisant une fonction plus adaptée pour ça.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

#include <stdio.h>

int main(void)
{
    FILE * fichier = fopen("test.txt", "w");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    fputs("Salut", fichier);

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Pour terminer, sachez que contrairement à puts, la fonction fputs n'ajoute pas de '\n' final.

La grande famille de printf

Tout comme scanf, la fonction printf a une sœur permettant d'écrire sur un flux quelconque : fprintf. En voici le prototype :

1

int fprintf (FILE * stream, const char * format, ...);

Elle écrit tous les arguments passés en paramètres dans le fichier stream : elle fonctionne exactement comme printf, à la différence près que le premier argument est un FILE*. Voici un exemple qui écrit dans un flux le pseudo et le score d'un joueur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

#include <stdio.h>

int main(void)
{
    FILE * fichier;
    const char * pseudo = "Lecteur";
    const int score = 50;

    fichier = fopen("test.txt", "w");
    if(fichier == NULL)
    {
        perror("fopen");
        /* ... */
    }

    fprintf(fichier, "%s %d\n", pseudo, score);

    fclose(fichier);
    fichier = NULL;
    return 0;
}

Vous pouvez vérifier dans le fichier, le nom et le score du joueur ont bien été inscrits, suivis d'un retour à ligne. Afin de vous entrainez, pourquoi ne pas reprendre notre structure de la sous-partie précédente et s'entrainer à écrire chacun des membres dans un flux ?

Enfin, sachez que fprintf est également très utile pour signaler les erreurs. Il suffit d'écrire dans le flux stderr. Pourquoi un simple printf / puts ne suffirait-il pas ? Tout d'abord, printf / puts écrivent sur stdout. Dans notre cas, que l'on écrive sur stderr ou stdout, cela revient au même puisque tout est affiché sur la console. Or, si jamais vous utilisez plus tard des bibliothèques graphiques, il se peut que les deux flux soient bien distincts. Il sera donc important de bien séparer les messages d'erreur des messages informatifs. Il se peut également que la console ne se lance pas (c'est le cas dans la grande majorité des applications). Comment faire alors pour afficher des messages ? Voilà pourquoi on préfère utiliser fprintf pour remonter les erreurs.

D'autres fonctions

En plus de nous permettre de lire et d'écrire dans un fichier, la bibliothèque standard définie d'autres fonctions pour manipuler les flux. Nous allons en voir quelques-unes.

Manipuler le curseur

Nous avons parlé tout au long de ce chapitre du curseur virtuel qui indique où l'on se situe dans un fichier. Les fonctions que nous avons vu déplace le curseur pour nous, mais il existe des fonctions qui nous permettent de le faire manuellement. Nous allons en voir trois.

ftell - Déterminer la position du curseur

Cette fonction renvoie la position actuelle du curseur dans le flux passé en paramètre. Son prototype est :

1

long ftell(FILE * stream);

Je pense que vous n'avez pas besoin d'exemple, la fonction parle d'elle-même.

fseek - Repositionner le curseur

Voici le prototype de la fonction :

1

int fseek (FILE * stream, long offset, int whence);

Détaillons ces trois paramètres un par un. Comme à l'accoutumée, l'argument stream correspond au flux sur lequel on souhaite agir. Ensuite, il s'agit de la valeur du déplacement du curseur. En mode texte, cette valeur doit être obligatoirement une valeur retournée par ftell ou 0. C'est le seul comportement garantit par la norme. Si l'on passe une autre valeur, alors on dit que le comportement dépend de l'implémentation : ça marchera sous une plate-forme X avec un compilateur Y mais pas avec une autre. C'est donc quelque chose à éviter, sauf si vous visez une plate-forme particulière.

Pour finir, whence, qui signifie « d'où » en français, représente l'origine , le point de départ utilisé par offset pour déplacer le curseur. Trois constantes sont fournies :

  • SEEK_SET : début du fichier ;
  • SEEK_CUR : position actuelle du curseur ;
  • SEEK_END : fin du fichier ;

Dans le cas du mode texte, pour avoir un code conforme à la norme, ce paramètre doit obligatoirement être SEEK_SET. Une autre valeur rendra le comportement du programme dépendant de l'implémentation.

La fonction retourne 0 si elle réussit, une valeur différente de 0 si elle échoue.

rewind - Repositionner le curseur au début

Cette fonction sert simplement à remettre le curseur au tout début du fichier. Voici son prototype, qui parle pour lui :

1

void rewind(FILE * stream);

Préférez l'appel de cette dernière que fseek (pour cet emploi du moins), pour des raisons de clarté et de simplicité.

Opérations diverses

rename - Renommer un fichier

Cette fonction permet de renommer un fichier passé en paramètre par un nouveau nom :

1

int rename (const char * oldname, const char * newname);

Si newname désigne un fichier déjà existant, la fonction peut soit échouer, soit écraser l'autre fichier déjà présent, tout dépend du système d'exploitation. Si la fonction échoue, elle retourne encore une fois une valeur différente de 0.

remove - Supprimer un fichier

Cette fonction supprime tout simplement du disque dur le fichier désigné par la chaîne de caractères passée en paramètre, sans confirmation ni possibilité de récupération (donc prudence). Voici son prototype :

1

int remove (const char * filename);

Si elle réussit, elle retourne la valeur 0 sinon une autre valeur.


Entrées sécurisées

L'une des plus grandes règles dans le monde de la programmation est de ne jamais faire confiance à l'utilisateur. Cette règle est particulièrement importante lorsqu'on demande des informations : qui nous prouve que l'utilisateur va répondre correctement, qu'il ne va pas nous envoyer une chaîne de caractères au lieu d'un int ? Il est donc important d'apprendre à sécuriser le mieux possible ses entrées, et c'est le but de ce chapitre.

  • Les dangers de scanf
  • Des alternatives
  • Comment bien utiliser scanf ?

Les dangers de scanf

Depuis le début de ce cours nous utilisons la fonction scanf dès qu'il y a besoin de récupérer des informations auprès de l'utilisateur. Elle est relativement simple à première vue, et c'est pour cela que nous l'avons utilisée depuis le début, mais elle est en réalité complexe et potentiellement dangereuse. C'est ce que nous allons illustrer dans cette partie.

Chaîne de caractères avec des espaces

Le premier problème vient d'une chaîne de caractères qui contiendrait des espaces. En effet, avec l'utilisation du format %s (qui sert à récupérer des chaînes de caractères), scanf supprime les espaces avant et après le premier mot de la chaîne. Du coup, lorsque que l'utilisateur rentre une chaîne avec des espaces, scanf ne va prendre que le premier mot.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    char chaine[20];

    printf("Ecris une phrase s'il-te-plait : \n");
    scanf("%s", chaine);
    printf("Entree : '%s'\n", chaine);

    return 0;
}

1
2
3

Écris une phrase s'il-te-plait :
Salut ca va ?
Entrée : 'Salut'

Où est passé le reste des caractères rentrés ? Il sont stockés dans le buffer stdin, c'est à dire la zone mémoire qui stocke tous les caractères rentrés par l'utilisateur. Et le problème est que la prochaine fois que scanf sera appelée, elle ne demandera rien à l'utilisateur et les caractères non-extraits seront stockés dans la chaîne, ce qui est assez embêtant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

#include <stdio.h>

int main(void)
{
    char chaine[20];

    printf("Ecris une phrase s'il-te-plait : \n");
    scanf("%s", chaine);
    printf("Entree : '%s'\n", chaine);

    printf("Une autre : \n");
    scanf("%s", chaine);
    printf("Entree : '%s'\n", chaine);

    return 0;
}

1
2
3
4
5

Écris une phrase s'il-te-plait :
Salut ça va ?
Entrée : 'Salut'
Une autre :
Entrée : 'ça'

On ne peut même pas rentrer quoi que ce soit, scanf va directement dans le buffer chercher le prochain mot, et ainsi de suite jusqu'à ce qu'elle ait tout récupéré. Nous verrons dans une partie suivante comment vider le buffer.

Chaîne de caractères trop longue

Un des plus grands dangers avec scanf est lorsque l'on rentre une chaîne de caractères trop longue. Imaginons par exemple que l'on définisse un tableau de 10 char. On peut donc rentrer au maximum 9 caractères. Regardez ce qui se passe lorsqu'on dépasse cette limite.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    char chaine[10];

    printf("Ecris une phrase s'il-te-plait : \n");
    scanf("%s", chaine);
    printf("Entree : '%s'\n", chaine);

    return 0;
}

1
2
3

Écris une phrase s'il-te-plait :
Anticonstitutionnellement
Entrée : 'Anticonstitutionnellement'

Ce qui semble anodin à première vue cache un problème grave : en effet, scanf a écrit tous les caractères en mémoire, alors qu'il n'y avait pas la place pour. Où les a t-elle écrit ? A la suite, mais sur des cases mémoires qui n'appartient pas au programme. C'est un dépassement de tampon (buffer owerflow). Et bien souvent, le système d'exploitation arrête le programme afin de l'empêcher de faire des dégâts. Il est donc très facile de causer des plantages si l'on n'y prend pas garde.

Entrée erronée

Un autre problème, que vous avez peut-être déjà constaté, arrive lorsque scanf attend un certain type de données (des nombres par exemple) et qu'on rentre autre chose (des caractères). Tout peut alors arriver :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>

int main(void)
{
    int n;

    printf("Donne un nombre : ");
    scanf("%d", &n);
    printf("Merci pour le nombre %d\n", n);

    return 0;
}

1
2

Donne un nombre : dfdf
Merci pour le nombre 2147348480

La fonction fait parfaitement son boulot : elle récupère ce qui est entré et le stocke. Sauf que ça peut être problématique dans certains cas. Je n'ai eu ici qu'une simple valeur erronée (que j'aurais pu éviter en initialisant la variable à 0), mais j'aurais pu avoir plus grave : bugs latents, boucle infinie et même plantage.

Comme vous avez pu vous en rendre compte, scanf est potentiellement dangereuse si l'on n'y prend pas garde. Il est donc important d'apprendre à construire des entrées solides.

Des alternatives

Récupérer une chaîne

Pour pouvoir sécuriser les entrées de chaînes de caractères, nous allons réutiliser une fonction que nous avons vu dans le chapitre précédent : fgets. Je vous rappelle le prototype de cette fonction :

1

char * fgets(char * s, int size, FILE * stream);

Cette fonction lit un certain nombre de caractères d'un flux et écrit ce qu'elle lit dans une chaîne de caractères. On s'en sert habituellement pour lire dans les fichiers, mais on peut aussi lui passer le flux stdin pour récupérer ce que tape l'utilisateur. Essayons de coder un programme qui récupère pile-poil le bon nombre de caractères. On peut pour cela s'aider de sizeof. Voici mon code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <stdio.h>

int main(void)
{
    char chaine[20];
    size_t n = sizeof(chaine);

    printf("Entrez une chaine : ");
    fgets(chaine, n, stdin);
    printf("Voici la chaine rentree : %s\n", chaine);

    return 0;
}

1
2

Entrez une chaine : Salut ca va ?
Voici la chaine rentrée : Salut ca va ?

Notez que j'utilise une variable intermédiaire pour stocker la taille du tableau. Dans notre exemple c'est inutile (on aurait pu mettre sizeof(chaine) directement dans l'appel de fgets), mais je prévois si jamais on devait appeler fgets dans une fonction : comme un tableau est converti en pointeur constant sur son premier élément, en faisant sizeof nous aurions eu la taille du pointeur et non de tout le tableau. En passant par une variable, on est sur de conserver tout le temps la taille du tableau.

Comme fgets limite le nombre de caractères lus, il n'y a pas de risque de dépassement, même si la chaîne rentrée est plus grande que le tableau :

1
2

Entrez une chaine : Anticonstitutionnellement
Voici la chaine rentrée : Anticonstitutionnel

Le seul problème, c'est que les caractères non-lus sont toujours dans le buffer, et il faut le vider sinon fgets lira directement le buffer sans demander quoi que ce soit à l'utilisateur. Il faut donc que nous codions nous-mêmes une fonction.

Une fonction de saisie

L'idée est de coder une fonction qui va lire une chaîne de caractère en appelant fgets, mais qui va en plus supprimer le retour à la ligne et vider le buffer. Mais comment vide t-on un buffer ?

Vider un buffer

Grâce à fflush. Cette fonction, dont le prototype est juste en dessous, permet de vider un buffer des caractères qu'il contient.

1

int fflush (FILE * stream);

Elle est utilise notamment dans le cas des sorties. Je m'explique. Pour qu'un flux soit affiché à l'écran, il faut que l'une des trois conditions suivantes soit remplie.

  • Le buffer est plein. On ne peut pas contrôler cet évènement.
  • Il faut qu'il y ait le caractère '\n'.
  • Ou bien il faut que fflush soit appelée.

Ainsi, on s'en sert par exemple après un appel d'une fonction de sortie quand la chaîne de caractères passée en argument ne contient pas de '\n'. En effet, il se peut que ça marche sans appeler cette fonction, mais rien ne garantit que ça marchera tout le temps. Pour s'en assurer, on appelle fflush. Exemple avec un code tout bête :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

#include <stdio.h>

int main(void)
{
    int var = 42;

    printf("Entrez un entier : ");
    fflush(stdout);
    scanf("%d", &var);
    printf("Voici l'entier : %d\n", var);

    return 0;
}

L'idée est de coder une fonction qui va lire une chaîne de caractère en appelant fgets, mais qui va en plus supprimer le retour à la ligne et vider le buffer. Si notre fonction fflush marche parfaitement avec les flux de sortie, en revanche il est impossible de l'utiliser sur les flux d'entrées. Pourquoi cela ? Parce que la norme ne précise pas le comportement de cette fonction sur un flux d'entrée. Son utilisation peut donc aussi bien marcher que planter ; c'est un comportement indéterminée. Puisque c'est ainsi, nous allons coder nous-mêmes une fonction capable de vider stdin. Et nous allons utiliser getchar, cette fonction qui lit un caractère de stdin. Il suffit de boucle tant qu'on obtient pas '\n' ou EOF pour vider entièrement le buffer. On obtient donc ceci :

1
2
3
4
5
6
7
8
9

void fflush_stdin(void)
{
    int c;

    do
    {
        c = getchar();
    } while (c != '\n' && c != EOF);
}
Notre fonction de saisie

Maintenant que nous savons vider stdin, vous avez toutes les clefs pour coder notre propre fonction de saisie sécurisée. Pensez à éliminer le '\n' final. Bon courage !

Voici ma version de la fonction. Elle est simple : on lit une chaîne avec fgets, on recherche le caractère '\n' et si on le trouve on le supprime, sinon on vide le buffer et on retourne -1 pour signaler l'erreur. Si tout s'est bien passé, on retourne 0.

Récupérer une valeur

Notre fonction ne sait lire pour l'instant que des chaînes de caractères. Si jamais on veut convertir une chaîne en nombre, il existe des fonctions que nous avons déjà vus dans le chapitre des chaînes de caractères : il s'agit de strtol et strtod. Il existe cependant une autre fonction capable de faire la même chose : sscanf.

Comme son nom l'indique, sscanf lit des caractères dans une chaîne. Voici son prototype :

1

int sscanf (char * chaine, const char * format, ...);

Son fonctionnement est le même que scanf, sauf qu'elle lit les caractères dans une chaîne et non dans stdin. Tout ce que vous savez de scanf s'applique à sscanf.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

int main (void)
{
    char chaine[20];
    size_t n = sizeof(chaine);
    int temp = 0;

    printf("Entrez un nombre : ");
    fgets(chaine, n, stdin);
    sscanf(chaine, "%d", &temp);
    printf("temp = %d\n", temp);

    return 0;
}

1
2

Entrez un nombre : 45
temp = 45

Mais là encore, les dangers qui nous guettaient avec scanf nous guettent aussi avec sscanf. Il nous faut donc apprendre à manier correctement scanf.

Comment bien utiliser scanf ?

Il se peut que la méthode que nous avons vu avec fgets vous paraisse trop lourde, et vous préféreriez continuer à utiliser scanf. Dans ce cas, je vais vous montrez qu'il est possible, mais parfois compliqué, d'utiliser correctement scanf.

Limiter le nombre de caractères lus

Tout comme fgets, on peut limiter le nombre de caractères que doit lire scanf. On peut ainsi limiter la lecture à 5, 10 ou 100 caractères par exemple. Pour se faire, il suffit de préciser le nombre de caractères à lire entre le % et l'indicateur de conversion. Comme une image vaut mille mots, voici un code qui limite la saisie à 7 caractères :

1
2
3
4

int n;

scanf("%7d", &n);
printf(">> %d\n", n);

1
2
3
4
5
6

125
>> 125
-45
>> -45
123456789
>> 1234567

On a par contre le même problème que fgets : les caractères non-lus restent dans le buffer. Il faut donc le vider en utilisant la fonction fflush_stdin, définie plus haut.

Cette technique marche aussi parfaitement avec les chaînes de caractères, sous réserve là encore de vider le buffer après la saisie.

1
2
3
4
5
6

char chaine[10];

printf("Entrez une chaine : ");
scanf("%9s", chaine);
fflush_stdin();
printf("Vous avez entre : %s\n", chaine);

1
2

Entrez une chaine : Anticonstitutionnellement
Vous avez entre : Anticonst

Les regexs

Abordons à présent le point le plus compliqué de scanf : les expressions régulières (de l'anglais regular expressions). Elles ne marchent que pour les chaînes de caractères et sont placées entre crochets. Grâce à elles, on peut choisir quel type de caractères récupérer (des chiffres, des minuscules, des majuscules, etc). Voici des exemples de regexs (vous noterez qu'on ne met plus le s comme on en avait l'habitude pour les chaînes de caractères) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

char chaine[81] = {0}; /* doit pouvoir contenir tous les caractères dont le '\0' de fin */

/* Les chiffres de 0 à 9. */
scanf("%80[0-9]", chaine);

/* Les minuscules de 'a' à 'z'. */
scanf("%80[a-z]", chaine);

/* Les minuscules de 'a' à 'z' et les majuscules de 'A' à 'Z'. */
scanf("%80[a-zA-Z]", chaine);

/* Que les lettres de 'd' à 'y' (et de 'H' à 'L') et les chiffres de 2 à 7. */
scanf("%80[d-yH-L2-7]", chaine);

Essayez par exemple de faire un programme qui ne lit que les minuscules de 'a' à 'm', et les chiffres de 4 à 9. Il faut bien sur penser à vider le buffer après l'appel de scanf. Avec mon code, vous devriez obtenir une exécution semblable à celle ci-dessous.

1
2

Entrez une chaine : abcdtmh785
Vous avez entre : abcd

On peut aussi faire lister les caractères que l'on ne veut pas récupérer en utilisant le symbole ^ :

1
2
3
4

char chaine[81];

/* On récupère tout sauf les chiffres de 4 à 9. */
scanf("%80[^4-9]", chaine);

Cela permet de vider le buffer d'une autre façon. Il suffit de ne lire aucun caractère sauf le retour à la ligne et l'éliminer à l'aide de getchar.

1
2
3
4
5
6
7
8
9

char chaine[21];

/* On récupère 20 caractères. */
scanf("%20s", chaine);

/* Puis on vide le buffer en ne gardant aucun caractère ... */
scanf("%*[^\n]");
/* ... avant de supprimer le '\n' final.*/
getchar();

Il est donc possible de faire des entrées sécurisées avec scanf, mais cela reste assez complexe et réclame de la rigueur. Enfin, sachez que ce que nous venons de voir est conforme à la norme et marchera partout. Il existe d'autres expressions régulières qui ne sont pas standards et que nous n'aborderons donc pas ici.


T.P - zAnalyse

Énoncé

zAnalyse

Cet exercice vous permettra de manipuler des chaînes de caractères et des fichiers. En effet, vous serez amené à analyser un texte (compter le nombre de paragraphes, de lignes, de mots, de caractères).

Tout d'abord, fixons les règles. Un mot est une suite de caractères suivie ou précédant un espace. Un caractère peut représenter une lettre minuscule, une lettre majuscule, un chiffre ou un signe de ponctuation, mais aussi une espace typographique, une tabulation, un retour à la ligne, etc. Une ligne se finit par un retour à la ligne. Un paragraphe représente ici deux retours à la lignes consécutifs.

Niveau facile

Tout d'abord, l'utilisateur entrera une chaine de caractères, vous ne compterez seulement que :

  • Le nombre de paragraphes
  • Le nombre de lignes
  • Le nombre de mots
  • Le nombre de caractères

Voici un exemple possible d'exécution :

1
2
3
4
5

Votre fichier contient :
    n paragraphes
    n lignes
    n mots
    n caractères

Niveau intermédiaire

Un peu plus compliqué, vous devez désormais compter :

  • Le niveau facile
  • Le nombre de chacune des lettres de l'alphabet (sans distinguer les majuscules, les minuscules et les accents).
  • Le nombre de caractères autres (ponctuation, slash, chiffres, etc). L'espace, le retour à la ligne, la tabulation horizontale et le retour chariot ne seront pas pris en compte.

Si il n'y a aucune occurrence d'un certain caractère, ne pas l'indiquer. Voici un exemple possible d'exécution :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

Votre fichier contient :
    n paragraphes
    n lignes
    n mots
    n caractères
        dont :
            n fois la lettre a
            n fois la lettre b
            ...
            n fois la lettre z
        et n autres caractères

Niveau difficile

Vous en voulez encore, ça tombe bien, j'ai plusieurs idées. La chaîne de caractères est toujours dans un fichier, vous devrez écrire le résultat dans un autre fichier :

  • Le niveau facile ainsi que le niveau intermédiaire
  • Le nombre moyen de caractères par mot
  • Le ou les mot(s) le(s) plus long(s) ainsi que le nombre de caractères qu'il contient

Voici un exemple possible d'exécution :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17

Votre fichier contient :
    n paragraphes
    n lignes
    n mots
        dont :
            n fois le mot "et"
            n fois le mot langage
            ...
        avec n caracteres par mot en moyenne
        ainsi que le mot le plus long est programmation (n caracteres)
    n caractères
        dont :
            n fois la lettre a
            n fois la lettre b
            ...
            n fois la lettre z
        et n autres caractères

Prenez votre temps, réfléchissez, ce n'est pas une course. Bonne chance !


Opérations bit à bit et champs binaires

Le C est souvent qualifié de langage bas-niveau et ce terme n'est pas usurpé. En effet, en plus des pointeurs qui nous font souvent mettre les mains dans le cambouis, le C propose un mécanisme de manipulation de bits que nous allons découvrir dans ce tutoriel. Un autre tutoriel plus général existe sur PDP ; n'hésitez pas à le consulter en parallèle pour approfondir vos connaissances. Quant à nous, nous allons voir ces opérations bitwise à travers le C.

Mais avant de commencer, et afin d'éviter de dire répéter plusieurs fois le même contenu dans différents tutoriels, je vous encourage, si jamais les notions de décalages, de XOR et autres vous sont inconnues, à lire la première partie du tutoriel de mewtow et Lucas-84.

  • Les opérateurs bitwise
  • Le masquage et les champs de bits
  • Quelques astuces

Les opérateurs bitwise

Le langage C définit six opérateurs dit bitwise. Les voici :

  • & : correspond au ET bitwise (AND) ;
  • | : correspond au OU bitwise (OR) ;
  • ^ : correspond au OU exclusif bitwise (XOR) ;
  • ~ : correspond au NON bitwise (NOT) ;
  • >> : correspond à un décalage vers la droite (right shift) ;
  • << : correspond à un décalage vers la gauche (left shift) ;

Les opérateurs ET bitwise (&) et OU bitwise (|) ne doivent pas être confondus avec les opérateurs ET logique (&&) et OU logique (||). Ce sont deux choses aussi différentes que le sont l'opérateur d'affectation = et l'opérateur de comparaison ==.

Les opérateurs appliqués en C

Voici un simple code qui illustrera bien mieux le sujet que milles mots.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

#include <stdio.h>

#define OP(x, y, op) printf("%x " #op " %x = %x\n", (x), (y), (x) op (y))

int main(void)
{
    int a = 0xF0; /* == 1111 0000 */
    int b = 0x0F; /* == 0000 1111 */
    int c = 0x0; /* == 0000 0000 */

    /* 1111 0000
    &  0000 1111
    =  0000 0000 */
    OP(a, b, &);

    /* 1111 0000
    &  0000 0000
    =  0000 0000 */
    OP(a, c, &);

    /* 1111 0000
    |  0000 1111
    =  1111 1111 */
    OP(a, b, |);

    /* 1111 0000
    |  0000 0000
    =  1111 1111 */
    OP(a, c, |);

    /* 1111 0000
    ^  0000 1111
    =  1111 1111 */
    OP(a, b, ^);

    /* 1111 0000
    ^  0000 1111
    =  1111 1111 */
    OP(a, c, ^);

    return 0;
}

Enfin, avant de continuer, sachez que, de même que la plupart des opérateurs que nous avons vu, il existe une forme raccourcie que voici.

  • a = a & b
     est équivalent à a &= b
     ;
  • a = a | b
     est équivalent à a |= b
     ;
  • a = a ^ b
     est équivalent à a ^= b
     ;
  • a = a >> b
     est équivalent à a >>= b
     ;
  • a = a << b est équivalent à a <<= b.

Les opérateurs de décalage

Bit de poids fort / faible

Avant toute chose, il est important de définir ce qu'est un bit de poids fort (en anglais Most significant bit) et un bit de poids faible (en anglais Least significant bit). C'est très simple.

  • Le bit de poids faible est le bit de moindre importance du nombre ; en clair, le bit le plus à droite dans la réprésentation usuelle.
  • Le bit de poids fort est le bit de plus forte importance du nombre ; en clair, le bit le plus à gauche dans la réprésentation usuelle.

Ceci étant dit, vous comprendrez mieux les sous-parties suivantes.

Décalage à droite

L'opération a >> b décale b bits de a vers la droite. Les bits de poids faible sont perdus ; on complète le nombre avec des bits de poids fort valant 0. Exemple :

1
2
3

int a = 0xA0; /* == 1010 0000 == 160*/

a >> 3; /* == 0001 0100 == 0x14 ==  20*/

Si l'expression est signé, le résultat peut être signé. Mais comme le résultat dépend de l'implémentation, il est conseillé de ne pas faire de décalage vers la droite sur un entier négatif.

Autre remarque : décaler de $n$ bits vers la droite revient à diviser par $2^n$. En effet, dans notre exemple, $160 = 20 \times 2^3 = 20 \times 8$.

Décalage à gauche

L'opération a << b décale b bits de a vers la gauche. Les bits de poids fort sont perdus ; on complète le nombre avec des bits de poids faible valant 0. Exemple :

1
2
3

int a = 0xA0; /* == 1010 0000 == 160*/

a << 3; /* == 0101 0000 0000 == 0x500 ==  1280*/

A l'opposé du décalage vers la droite, cette fois, décaler de $n$ bits vers la gauche revient à multiplier par $2^n$. En effet, dans notre exemple, $1280 = 160 \times 2^3 = 160 \times 8$.

Exercice : afficher la représentation base 2 d'un entier

Il serait quand même plus pratique de pouvoir obtenir la représentation binaire d'un nombre pour mieux visualiser les opérations que l'on fait. Or, si printf fournit des indicateurs de conversions pour l'octal (%o) et pour l'hexadécimal (%x, avec la représentation en majuscules avec %X), elle ne fournit rien pour le binaire. Soit, nous allons le faire nous mêmes dans ce cas.

Réfléchissons. Comment tester, tout d'abord, si un bit est à 1 ou à 0. Quel opérateur bitwise allons-nous utiliser ? L'opérateur ET (&) ? Oui, c'est bien lui. Pourquoi ? Parce que cet opérateur ne renvoie 1 que si le bit testé vaut 1 ; il renvoie 0 dans les autres cas. Ainsi, en testant sa valeur, nous savons si le bit vaut 1 ou 0.

D'accord, mais un nombre est constitué de plusieurs bits. Comment tous les tester ? Réfléchissons. Quels opérateurs vient-on tout juste de découvrir ? Les opérateurs de décalage. En quoi vont-ils nous être utiles ? Le code ci-dessous devrait vous aider à trouver comment nous allons procéder.

1

printf("0x%x", 1 << 3);

Ce code affiche 0x8. Or, quelle est la représentation en binaire de ce nombre ? C'est 0b1000. Avez-vous remarqués la particularité de ce nombre ? Seul le bit n°3 vaut 1, tous les autres valent 0. Et alors ? Eh bien avec ce nombre, on peut tester le bit n°3 d'un autre nombre. Exemple ci-dessous.

1
2
3
4
5

int a = 0x8;
int b = 0x10;

printf("bit n°3 de a = %d\n", a & (1 << 3));
printf("bit n°3 de b = %d\n", b & (1 << 3));

On obtient les valeurs 8 et 0. Qu'en déduit-on ? Posons l'opération en utilisant la représentation binaire. On sait que 0x8 est équivalent à 0b1000. Si on applique la table de vérité de l'opérateur ET, on obtient $0b1000 & 0b1000 = 0b1000$. On comprend donc que l'on obtienne 8. De même, 0x10 est équivalent à 0b10000, donc $0b10000 & 0b1000 = 0b00000$. On comprend donc que l'on obtienne 0.

Avez-vous saisi le principe ? Il suffit de faire une boucle pour tester tous les bits du nombre et nous obtiendrons sa représentation binaire. D'ailleurs, si on peut commencer par le bit de poids faible et progresser vers le bit de poids fort avec l'opérateur <<, rien ne nous empêche de faire l'inverse, c'est à dire de commencer par le bit de poids fort et progresser vers le bit de poids faible avec l'opérateur >>. Seulement, comment connaître le nombre de bits composant un unsigned int par exemple ?

Heureusement pour nous, le langage C nous fournit les limites des types entiers dans le fichier d'en-tête <limits.h>. Une des constantes en particulier nous intéresse : UINT_MAX, qui donne la valeur maximum que peut contenir un unsigned int. Comme c'est la valeur maximum, tous ces bits sont à 1. Tel quel, on peut se demander à quoi cela va nous servir. Mais essayez de décaler cette valeur d'un rang vers la droite. Cette fois, le bit de poids fort est à 0 ; tous les autres bits restent à 1. Ceci est normal, puisque décaler d'un rang vers la droite équivaut à diviser par 2.

Et maintenant, essayons de lui appliquer l'opérateur NOT (~). Cette fois, le premier bit sera le seul à 1, tous les autres seront à 0. Tiens, mais c'est parfait ! Grâce à cette valeur, nous pouvons tester le bit de poids fort. Il suffit ensuite de décaler d'un rang vers la droite à chaque tour de boucle pour tester le bit suivant, jusqu'à arriver au bit de poids faible. Cela vous parait un peu compliqué ? Alors ce code C devrait vous aider à y voir plus clair.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

#include <stdio.h>
#include <limits.h>

void bin_print_int(unsigned number)
{
    unsigned mask = ~(UINT_MAX >> 1);

    putchar('0');
    putchar('b');
    while (mask != 0)
    {
        if (number & mask)
        {
            putchar('1');
        }
        else
        {
            putchar('0');
        }
        mask >>= 1;
    }
    putchar('\n');
}

int main(void)
{
    bin_print_int(UINT_MAX);
    bin_print_int(UINT_MAX >> 1);
    bin_print_int(~UINT_MAX);
    bin_print_int(~(UINT_MAX >> 1));

    return 0;
}

Avez-vous remarqué la variable mask, ou masque en français ? Nous allons voir tout de suite qu'elle porte parfaitement bien son nom.

Le masquage et les champs de bits

Les masques

Qu'est ce qu'un masque ? Il s'agit d'une valeur binaire de la même taille que la valeur à masquer qui permet de modifier un ou plusieurs bits en une seule opération à l'aide des opérateurs bitwise. Nous en avons vu un exemple dans la partie précedente. En effet, pour tester le bit n°3 d'un nombre, nous avons utilisé la valeur 1 << 3, qui vaut 0b1000 ; seul le bit n°3 vaut 1, les autres étant à 0. Nous pouvions ainsi tester le bit n°3 de n'importe quel nombre. De même, dans notre fonction bin_print_int, nous avons utilisé des masques pour tester chaque bit de notre nombre et ainsi afficher sa représentation en binaire.

A quoi peuvent bien servir les masques, en plus de ce que nous avons vu ? Eh bien grâce à eux, nous allons pouvoir modifier directement un ou plusieurs bits d'un entier !

Mettre un bit à 1

Commençons par trouver quel opérateur nous servira dans ce cas précis. Si vous relisez les tables de vérités des opérateurs (disponibles dans le tutoriel cité en introduction), vous remarquerez que l'opérateur OR (|) convient très bien pour cela, puisque x | 0 = x et x | 1 = 1. Reste à définir notre masque.

Prenons notre valeur de test 0xF0. Sa représentation binaire usuelle vaut 0b1111 0000. Supposons que l'on veuille mettre le bit n°8 à 1. Il faut donc un masque où seul le bit n°8 est à 1, soit 0b0001 0000 0000 ou encore 0x100. En appliquant l'opérateur OR, on obtient bien la valeur souhaitée, soit 0b0001 1111 0000.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

int main(void)
{
    int a = 0xF0;
    int masque = 0x100;
    bin_print_int(a);
    bin_print_int(masque);

    a |= masque;
    bin_print_int(a);
    printf("a = 0x%x\n", a);

    return 0;
}

Mettre un bit à 0

De nouveau, examinons les tables de vérités. Il nous faut un opérateur qui transforme les 1 en 0 et ne touche pas aux 0. Et l'opérateur AND (&) fait exactement ce que nous voulons : x & 1 = x et x & 0 = 0.

Reprenons encore notre valeur 0xF0, mais cherchons cette fois à mettre le bit n°5 à 0. Nous avons donc besoin d'un masque où tous les bits valent 1, sauf le bit n°5 qui vaudra 0. Nous obtenons donc le masque 0b1101 1111 soit 0xDF. En appliquant l'opérateur AND, nous obtiendrons la valeur 0b1101 0000, ce qui est bien ce que nous recherchons.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

int main(void)
{
    int a = 0xF0;
    int masque = 0xDF;
    bin_print_int(a);
    bin_print_int(masque);

    a &= masque;
    bin_print_int(a);
    printf("a = 0x%x\n", a);

    return 0;
}

Se fabriquer un masque plus facilement

Il n'est pas toujours évident de trouver de tête le masque, de faire des conversions dans tous les sens entre plusieurs bases, et même si il existe des outils comme la calculatrice fournie avec la plupart des systèmes d'exploitation, nous n'avons pas envie de jongler entre plusieurs fenêtres. Non, ce que nous voulons, c'est que ce soit le programme qui nous fournisse le masque adapté en fonction du bit à changer.

Dans le cas d'une mise à 1 à l'aide de l'opérateur OR, on remarque que le masque s'obtient en faisant 1 << noù n est le numéro du bit que l'on veut modifier. On peut donc se définir une petite fonction qui nous fera ce calcul à notre place.

1
2
3
4

int or_mask(int n)
{
    return 1 << n;
}

Et dans le cas d'une mise à 0 à l'aide de l'opérateur AND ? Si on observe bien, c'est le même masque que le précédent, à la seule différence que les bits 0 sont transformés en 1 et vice-versa. Or, quel opérateur permet d'inverser la valeur des bits ? C'est l'opérateur NOT (~). On en déduit donc que notre masque s'obtient en faisant ~(1 << n). Nous pouvons donc écrire notre petite fonction.

1
2
3
4

int and_mask(int n)
{
    return ~(1 << n);
}

Testons donc avec ce petit code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

int main(void)
{
    int a = 0xF0;
    int masque_ou = or_mask(8);
    int masque_et = and_mask(5);

    bin_print_int(a | masque_ou);
    bin_print_int(a & masque_et);
    return 0;
}

On obtient les mêmes valeurs que précédemment. Nous pouvons donc nous épargner du travail supplémentaire en laissant l'ordinateur nous donner le masque. Cette méthode présente néanmoins des inconvénients, comme par exemple le fait de ne pas pouvoir créer directement un masque modifiant plusieurs bits. Il faudra pour cela faire des opérations sur les résultats de la fonction.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

int main(void)
{
    int a = 0xF0;
    int masque_ou = or_mask(8) | or_mask(10);
    int masque_et = and_mask(5) & and_mask(6);

    bin_print_int(a | masque_ou);
    bin_print_int(a & masque_et);
    return 0;
}

Les champs de bits

Nous le savons, lorsque l'on créé une structure, sa taille est la somme de la taille de tous ces éléments plus quelques bits de padding en fonction des cas. Pourtant, il est possible de définir une structure qui contiendra des portions d'entiers ! Comment est-ce possible ? Eh bien cela marche comme une structure classique sauf qu'en plus on précise, pour chaque champ, la taille en bits qu'il occupera. Exemple.

1
2
3
4
5
6

struct bit_field
{
    unsigned field1 : 3; /* 1er champs : 3 bits */
    unsigned field2 : 1; /* 2ème champ : 1 bit */
    unsigned field3 : 7; /* 3ème champs : 7 bits */
};

Bien entendu, la taille d'un champ de bits ne peut dépasser celle d'un entier et les champs de bits ne sont applicables de façon définie et portable qu'aux entiers non-signés. Hormis ces contraintes, ces structures s'utilisent comme des structures habituelles.

Mais quel est donc l'intérêt de cette technique ? Il n'est pas très évident à voir, car hormis certains cas précis, vous ne la verrez que très rarement. Elle sert principalement quand on a besoin de stocker beaucoup d'informations et qu'on dispose de peu de mémoire. Prenons un exemple tiré du tutoriel de mewtow et Lucas-84, celui d'un développeur qui veut stocker une date (constitué d'un jour, d'un mois et d'une année).

On sait qu'un jour sera toujours compris entre 1 et 31. Or 31 s'écrit aussi 0b11111 ; 5 bits suffisent donc. De même, un mois sera toujours compris entre 1 et 12. Ce dernier s'écrivant 1100, on en déduit que 4 bits suffisent. Enfin, en supposant qu'on stocke l'année jusqu'en 2047, qui s'écrit 0b11111111111, on obtient 11 bits. Notre structure s'écrira donc ainsi :

1
2
3
4
5
6

struct date
{
    unsigned jour : 5;
    unsigned mois : 4;
    unsigned annee : 11;
};

Chez moi, sizeof(struct date) retourne 4. Sans préciser les champs de bits, j'obtiens 12. Nous avons donc économisé 8 octets. Une micro-poussière pour nous qui développons sur des ordinateurs communs, mais ces gains peuvent être utiles sur des processeurs embarqués ou sur des machines où la quantité de mémoire se compte en centaines d'octets.

Sachez que l'on peut également laisser des bits inutilisés ; il suffit simplement de ne pas donner de nom au champ. Cela est utile pour décrire certaines choses lorsque l'on fait de la programmation système. Par exemple, le registre d'état du MC 68030 de Motorola est décrit comme suit dans la documentation.

  • bit 0 : carry ;
  • bit 1 : overflow ;
  • bit 2 : zéro ;
  • bit 3 : négatif ;
  • bit 4 : extension ;
  • bits 5-7 : inutilisés ;
  • bits 8-10 : masque des interruptions ;
  • bit 11 : inutilisé ;
  • bits 12-13 : niveau de privilège ;
  • bits 14-15 : état des traces.

La structure qui décrit ce registre d'état peut s'écrire ainsi :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

struct sr
{
    unsigned int trace : 2;
    unsigned int priv : 2;
    unsigned int : 1;       /* inutilisé */
    unsigned int masque : 3;
    unsigned int : 3;       /* inutilisé */
    unsigned int extend : 1;
    unsigned int negative : 1;
    unsigned int zero : 1;
    unsigned int overflow : 1;
    unsigned int carry : 1;
};

Mais gardez bien en tête que c'est une méthode particulière réservée à des cas particuliers. Si vous développez sur un ordinateur commun, n'utilisez pas les champs de bits, qui ne ferons que compliquer la relecture du code sans rien apporter d'autre.

Quelques astuces

Jusqu'ici, nous avons vu majoritairement de la théorie, mais concrètement, à quoi ça sert vraiment tout ça ? Eh bien pour être honnête, dans la plupart des cas, ça ne vous servira pas vraiment. Cela ne veut pas dire que ça ne sert jamais, bien au contraire ! et je vais même vous le prouver à travers quelques exemples.

Gestion des flags

Pour définir ce que sont les flags et leur intérêt, imaginons un instant que nous soyons en train de développer une bibliothèque multimédia. Celle-ci peut ouvrir des fenêtres, jouer de la musique, manipuler le lecteur CD, les ports USB, se connecter au réseau, etc. Bref, elle contient énormément de modules complets et utiles. Seulement voilà : comment faire, lorsqu'on initialise le programme, pour ne charger que les modules qui nous intéressent ? Il est hors de question de créer une fonction prenant autant de paramètres qu'il n'y a de modules : imaginez un instant si nous avons 20 modules ! Il est également hors de question de charger tous les modules ; on ne veut payer que ce que l'on veut utiliser.

L'idéal serait de n'avoir qu'un seul paramètre et que, en fonction de ce que l'utilisateur rentre, on ne charge que les modules demandés. Eh bien les flags servent à ça ; ce ne sont rien d'autres que des valeurs qui, combinées ensembles, vont nous permettre de déterminer quels sont les modules à charger.

Mais ça ne nous rappelle pas quelque chose ? Regardez ce chapitre. Nous avons découvert les opérateurs bitwise et c'est encore eux qui vont nous servir ici. Et puis combiner des valeurs ensembles, ça ne nous rappelle pas un peu nos histoires de masques et d'opérateur OR ? Eh oui ! il nous suffit de combiner plusieurs valeurs à l'aide de l'opérateur OR pour n'avoir à passer qu'un seul paramètre.

Mais quelles valeurs donner à nos flags ? Il faut être certains qu'en les combinant, on ne puisse pas avoir de doute quant aux flags qui forment la combinaison. Nous pouvons par exemple ne prendre comme flags les puissances de 2, qui ont comme particularité de n'avoir qu'un seul bit à 1 et jamais le même qu'une autre puissance de 2.

1
2
3
4
5
6

#define NO_MODULE       (1 << 0)
#define AUDIO_MODULE    (1 << 1)
#define VIDEO_MODULE    (1 << 2)
#define NETWORK_MODULE  (1 << 3)
#define WINDOW_MODULE   (1 << 4)
#define MATHS_MODULE    (1 << 5)

Maintenant, nous avons un système simple qui nous permet de combiner les paramètres aussi facilement que l'on veut. Ainsi, si l'on veut charger le module de fenêtres et le module audio, il suffit de faire comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

int pdp_init(int flags)
{

}

int main(void)
{
    pdp_init(AUDIO_MODULE | WINDOW_MODULE);
    return 0;
}

Par contre, il faut que la fonction qui reçoit notre combinaison de flags puisse extraire chaque flag pour pouvoir faire ce qu'on lui demande. Et une fois n'est pas coutume, la réponse a été apporté dans ce chapitre : l'opérateur AND. En effet, si notre flag est présent dans l'argument de la fonction, alors argument & FLAG != 0. Dans notre exemple, voici ce que ça donnerait :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

int pdp_init(int flags)
{
    if (flags & AUDIO_MODULE)
    {
        /* On charge le module audio. */
    }

    if (flag & VIDEO_MODULE)
    {
        /* On charge le module vidéo. */
    }

    /* Etc */
}

Pour information, ce mécanisme est par exemple utilisé par la bibliothèque graphique SDL. Voici les flags qu'elle utilise :

1
2
3
4
5
6
7
8

#define SDL_INIT_TIMER      0x00000001
#define SDL_INIT_AUDIO      0x00000010
#define SDL_INIT_VIDEO      0x00000020
#define SDL_INIT_CDROM      0x00000100
#define SDL_INIT_JOYSTICK   0x00000200
#define SDL_INIT_NOPARACHUTE    0x00100000
#define SDL_INIT_EVENTTHREAD    0x01000000
#define SDL_INIT_EVERYTHING 0x0000FFFF

Puissances de 2

Nous l'avons dit il y a quelques instants : les puissances de 2 on la particularité de n'avoir qu'un bit à 1, tous les autres étant à 0. En particulier, $2^n$ a $n$ zéros à droite de son unique bit 1. Et si on retire 1 à ce nombre, on obtient l'inverse : un 0 suivit de $n$ 1 ; $2^n$ et $2^n - 1$ ont tous leurs bits opposés. C'est une particularité des puissances de deux (et de zéro, mais zéro n'étant pas une puissance de 2, il ne sera pas compté). Sachant cela, on peut donc créer une fonction disant si un nombre est une puissance de 2 ou non.

1
2
3
4

int is_power_of_two(unsigned int n)
{
    return n && (n & (n - 1)) == 0;
}

Pour continuer à voir l'intérêt des opérateurs bitwise et à vous entrainer, essayez de faire les calculs proposés dans cette partie du tutoriel cité en introduction.

Obtenir la valeur maximum d'un type non-signé

Une petite astuce : puisque 0, peut importe le type, a tous ses bits à 0, si on fait ~0, tous les bits passent à 1. On peut ainsi obtenir la valeur maximum d'un entier non-signé.

1
2

printf("%hu =?= %hu\n", ~0, USHRT_MAX);
printf("%lu =?= %lu\n", ~0, ULONG_MAX);

Les opérateurs bitwise ne sont pas très courant dans la plupart des codes, mais ils peuvent être très puissants, alors gardez-les dans un coin de votre tête, ils pourront vous servir un jour. Mais n'oubliez pas que cette puissance a un coût : une perte en lisibilité. Trop d'opérateurs bitwise rendent le code plus long à comprendre. Illustration d'un cas très tordu tiré du code source de Quake 3 Arena.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

float Q_rsqrt(float number)
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;

    x2 = number * 0.5F;
    y = number;
    i = *(long *)&y;
    i = 0x5f3759df - (i >> 1);
    y = *(float *)&i;
    y = y * (threehalfs - (x2 * y * y));
}

Ce code est particulièrement long à expliquer ; sachez simplement qu'il permet de calculer l'inverse d'une racine carrée et est extrêmement rapide. Mais il est fort à parier que vous n'aurez jamais à faire de code pareil. Tout ça pour dire que utilisez les opérateurs quand c'est nécessaire.


Enumérations et unions

Les énumérations et les unions sont des types composés un peu moins courant que les structures (surtout les unions) mais néanmoins utiles. Nous avons choisi d'en parler parce qu'on les retrouve suffisamment souvent pour qu'on les remarque, dans les codes anciens ou un peu "poussés" et parce qu'ils sont puissants quand on sait bien les utiliser.

  • Les énumérations
  • Les unions
  • Exemple pratique : une abstraction pour les chaînes de caractères

Les énumérations

Les bases

Les énumérations sont à première vue semblables aux #define puisqu'on s'en sert pour déclarer des constantes. Avant d'examiner plus en détail l'intérêt de cette nouvelle façon de faire, voici tout d'abord la syntaxe d'utilisation ci-dessous.

1
2
3
4

enum identificateur {nom_1, nom_2, ..., nom_n};

/* Voici la façon de créer une variable de type enum identificateur */
enum identificateur variable = nom_x;

En règle générale, on écrit les noms de valeurs entièrement en majuscules. Ainsi, si on souhaite énumérer les quatre axes d'une boussole, on procèdera comme dans l'exemple suivant.

1
2
3
4

enum Direction {NORD, SUD, EST, OUEST};

enum Direction urss = EST;
enum Direction usa = OUEST;

Peut-être êtes-vous en train de vous demander quel est le rapport avec les constantes de préprocesseur ? Eh bien il y a des points communs mais aussi des différences, dont la plus grande est sans doute la suivante : le compilateur associe automatique à chaque nom de valeur une constante entière en en commençant par zéro. Ainsi, dans notre exemple, NORD vaut 0, SUD vaut 1, EST vaut 2 et OUEST vaut 3. Et si cela ne nous convient pas, on peut associer nous-mêmes les valeurs que l'on veut, tant que celle-ci est entière.

1

enum Direction {NORD = 4, SUD, EST = 42, OUEST};

Que deviennent les constantes de valeurs auxquelles nous ne précisons rien ? Eh bien le compilateur leur associe la valeur supérieure à la valeur de la constante précédente : SUD vaudra donc 5 et OUEST vaudra 43.

Les énumérations face au préprocesseur

Maintenant vient la question fatidique : mais à quoi cela sert-il puisque nous avons déjà le préprocesseur qui fait un travail semblable et qui en plus nous permet d'utiliser autre chose que des constantes entières ? Tout d'abord, comme pour les variable ou les fonctions, les énumérations (ainsi que les constantes énumérées) peuvent disposer de différentes portées suivant le lieu de leur déclaration. Il est donc possible de limiter cette dernière à un bloc ou d'utiliser le mécanisme du masquage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

#include <stdio.h>

enum Couleur {VERT = -42};

int main(void)
{
    printf("VERT = %d\n", VERT);
    {
        enum Couleur {VERT = 42};

        printf("VERT = %d\n", VERT);
        {
            enum Couleur {VERT = 7};
            printf("VERT = %d\n", VERT);
        }
        printf("VERT = %d\n", VERT);
    }
    printf("VERT = %d\n", VERT);
    return 0;
}

Cela n'est pas réalisable (à tout le moins pas de cette manière) dans le cas d'une constante de préprocesseur puisque le préprocesseur n'a aucune notion de bloc ou de fonctions (la seule portée possible est donc au niveau d'un fichier).

Le second avantage est qu'il est plus facile de débugger un programme qui utilise des constantes énumérées plutôt que des constantes de préprocesseur. Pourquoi ? Parce que la plupart des débuggers utilisent le type d'une constante pour retrouver son identificateur, ce qui est impossible si on a affaire à une constante de préprocesseur : il faut utiliser des outils spécialisés pour débugger des macros puisqu'elles n'existent plus après le passage du préprocesseur.

Un troisième avantage est qu'il est, dans certains cas, plus pratique et facile d'ajouter des constantes énumérées que des constantes de préprocesseur. Comparez les deux codes suivants :

1
2
3
4
5
6

#define NORD 0
#define SUD 1
#define EST 2
#define OUEST 3

enum Direction {NORD, SUD, EST, OUEST};

Supposons que l'on veuille ajouter les directions intermédiaires (comme nord-ouest ou sud-est par exemple). Dans le premier cas, il faudra écrire huit lignes de plus, alors que dans le deuxième il suffira de rajouter ces noms de constante dans l'énumération. D'ailleurs, petite astuce, comme ce sont des constantes, on peut s'en servir pour retenir la taille d'un tableau. Ainsi, chaque fois qu'on ajoutera une constante à notre énumération, la taille augmentera elle aussi. Exemple avec des couleurs.

1
2
3
4
5

enum Couleurs{VERT, BLEU, JAUNE, ROUGE, NB_COULEURS};
enum Couleurs jetons[NB_COULEURS];
void compter(enum Couleurs jetons[], int taille);

compter(jetons, NB_COULEURS);

Bien entendu, les constantes énumérées possèdent aussi des défauts. Ainsi, la valeur d'une constante énumérée doit toujours être comprise entre la valeur minimale et la valeur maximale d'un int. Et s'il est besoin d'utiliser autre chose que des constantes entières, les énumérations ne peuvent rien pour nous.

Je conclus cette partie pour vous dire qu'il est possible de faire des pointeurs sur des types énumérations et qu'ils s'utilisent comme d'habitude.

1
2
3

enum Vins {BORDEAUX, BOURGOGNE, ALSACE};
enum Vins bordeaux = BORDEAUX;
enum Vins * vins_de_bordeaux = &bordeaux;

Les énumérations anonymes

Les énumérations anonymes ne sont ni plus ni moins des énumérations sans identificateur. Elles sont utiles quand on veut seulement déclarer des constantes et les utiliser sans passer par un type énuméré. Un exemple ? Vous souvenez-vous des booléens ? En C, nous n'avons aucun type pour définir une variable de type booléen, au contraire d'autres langages comme C++ ou Java. Certains programmeurs, pour rendre leur code plus clair, récréer ce type grâce à un typedef et une énumération.

1
2
3
4
5
6
7

enum {false, true};

/* On peut bien entendu utiliser le préprocesseur pour ça. */
#define false 0
#define true 1

typedef char bool;

C'est l'un des exemples les plus connus d'utilisation d'énumérations anonymes, mais certainement que vous en croiserez d'autres dans votre vie de programmeur.

Les unions

Les unions sont des agrégats en apparence identiques aux structures mais qui se révèlent différentes dans leur utilisation et plus rares dans les codes. Pour commencer, voici sa syntaxe.

1
2
3
4
5
6
7
8
9

union identificateur
{
    int entier;
    double flottant;
    char lettre;
};

union identificateur exemple;
exemple.entier = 42;

Pourtant, une différence majeure existe entre les structures et les unions : si la taille d'une structure est la somme de la taille de ses champs (plus éventuellement quelques bits de padding), la taille d'une union est la taille du plus grand de ses champs. Voyez par vous-mêmes en testant le code suivant (vous noterez que j'ai fait attention au padding dans la structure).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>

struct my_structure
{
    double d;
    char c;
    int i;
};

union my_union
{
    int i;
    double d;
    char c;
};

#define PRINT_SIZE(type) printf("sizeof(" #type ") = %u\n", sizeof(type))
int main(void)
{
    PRINT_SIZE(int);
    PRINT_SIZE(double);
    PRINT_SIZE(char);
    PRINT_SIZE(struct my_structure);
    PRINT_SIZE(union my_union);
    return 0;
}

En compilant ce programme, vous verrez que l'union a la taille d'un double. Les unions permettent donc d'économiser de la mémoire. En bons programmeurs que nous sommes, nous pourrions nous dire que les structures ne valent pas le coup par rapport aux unions puisqu'elles sont plus grosses. Mais ce n'est pas aussi simple car les unions ont un gros inconvénient que certains ont peut-être déjà deviné : puisque la taille est plus petite qu'une structure, toutes les champs partagent le même espace mémoire.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

#include <stdio.h>

struct my_structure
{
    double d;
    char c;
    int i;
};

union my_union
{
    int i;
    double d;
    char c;
};

#define PRINT_ADRS(var) printf("&" #var " = %p\n", &var)
int main(void)
{
    struct my_structure var_s;
    union my_union var_u;

    PRINT_ADRS(var_s.d);
    PRINT_ADRS(var_s.c);
    PRINT_ADRS(var_s.i);
    puts("");
    PRINT_ADRS(var_u.d);
    PRINT_ADRS(var_u.c);
    PRINT_ADRS(var_u.i);
    return 0;
}

Cela veut donc que quand on modifie un champ d'une union, on écrase les valeurs des autres champs. Les unions sont donc à manipuler avec précaution, car si elles le sont correctement, elles sont très puissantes, comme nous allons le voir.

Exemple pratique : une abstraction pour les chaînes de caractères

Nous sommes d'accord pour dire que les chaînes de caractères ne sont pas toujours aisées à manipuler : il faut vérifier que l'on ne déborde pas, il faut faire attention à bien libérer la mémoire dans le cas d'une allocation dynamique, à bien mettre le caractère nul final, etc. C'est contraignant, aussi nous allons faire ce que beaucoup d'autres langages proposent de base : une abstraction pour les chaînes de caractères qui nous masquera tous ces détails agaçants.

Une bonne idée serait de faire une structure qui contienne un pointeur sur char que l'on allouera et réallouera au fil du programme. Voilà, seulement nous ne voulons pas faire des allocations dynamique inutiles, car nous savons que la majorité du temps les chaînes de caractères ont une taille inférieure à 32. Nous décidons donc de stocker la chaîne dans un tableau statique si elle fait moins de 31 caractères, sinon dans un pointeur qui sera alloué. Dans ce dernier cas, il faudra penser à conserver la taille maximale de notre tableau dynamique. Voilà un exemple pratique d'utilisation des unions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

enum {false, true};
typedef char bool;

struct cstring
{
    union buffer
    {
        struct pointer { char * ptr; size_t capacity; } ptr;
        char array[32];
    } buffer;
    size_t length;
    bool is_in_ptr;
};

On pourra donc stocker des chaînes de n'importe quelle taille de façon totalement transparente pour l'utilisateur. C'est un bon exercice que je vous encourage à faire pour vous entrainer. Je ne vous donnerais pas de correction cette fois-ci car il existe de très nombreuses façons de créer une abstraction pour les chaînes de caractères, mais sachez que si besoin est, les forums seront là pour vous aider. Il faut surtout que vous reteniez qu'on peut utiliser soit le tableau statique, soit le pointeur alloué, mais pas les deux en même temps ; il faudra donc faire attention et bien pensez aux cas limites (lors des concaténations par exemple). Bon courage !


Découvrir le C99 / C11

Le langage C, comme n'importe quel langage vivant, évolue au fil du temps. En 1999, dix ans après la normalisation par l'ANSI et neuf ans après celle de l'ISO, ce dernier comité décida de mettre à jour le langage en rajoutant certains éléments. Cette norme porte le nom abrégé de C99. Enfin, en 2011, ce même comité décida de faire une nouvelle mise à jour du langage, notamment sur certains points bien trop compliqués pour nous, nommée C11. Néanmoins, même si nous ne présenterons pas tout des deux normes, nous avons sélectionné ce qui nous paraissait le moins compliqué et le plus intéressant. N'hésitez pas à fouiller un peu plus si le cœur vous en dit après.

Un dernier point avant de démarrer : ces normes n'ont eu que peu d'écho et contrairement au C89, elles ne sont pas supportées par certains compilateurs (Visual C++ par exemple, bien qu'il soit prévu un support du C99 pour VC++2013) ou incomplètement par d'autres. Il faudra donc faire attention si vous utilisez ces nouveautés dans l'un de vos programmes un jour.

  • Avant de démarrer
  • Découvrir le C99
  • Découvrir le C11

Avant de démarrer

Pour indiquer au compilateur que l'on souhaite compiler en C99 ou en C11, il faut modifier les options. Pour se faire, suivez les instructions ci-dessous en remplaçant XX par 99 ou 11 en fonction de la norme que vous utilisez.

  • Code::Blocks : aller dans Project -> Build Options -> Compiler settings -> Other options et rajouter -std=cXX.
  • GCC en ligne de commande : rajouter l'option -std=cXX dans les options de compilation.
  • Visual Studio : impossible pour le moment.

Ensuite, pour tester quelle version de C vous utilisez, vous pouvez utiliser la macro __STDC_VERSION__. Celle-ci peut prendre les valeurs ci-dessous.

  • Pas de valeur pour C90.
  • 199901L pour C99.
  • 201112L pour C11.

Enfin, voici quelques liens pour savoir quelles fonctionnalités sont supportés par quel compilateur.

  • GCC : C99 et C11

Découvrir le C99

Les commentaires mono-lignes

Il est maintenant possible d'utiliser la syntaxe du C++ pour les commentaires sur une seule ligne à savoir //. Comme leur nom l'indique, il n'est pas possible de faire un commentaire de plusieurs lignes avec excepté en rajoutant // à chaque nouvelle ligne ; personnellement je continue d'utiliser les commentaires du C90.

Le mot-clef long long

L'entier long long et son pendant non-signé unsigned long long sont conçus pour faire au minimum 64 bits de taille, ce qui permet de stocker de grands nombres.

Les booléens

Pour utiliser les macros définies par C99, il faudra inclure le fichier d'en-tête <stdbool.h>. Celui contient le type bool ainsi que deux macros true et false valant respectivement 1 et 0 et une macro __bool_true_false_are_defined valant 1 si les trois macros précédentes sont définies. Si jamais il n'est pas possible d'utiliser ce fichier d'en-tête ou que vous ne souhaitez pas le faire, il est possible d'utiliser le mot-clef _Bool qui peut prendre les valeurs 0 ou 1.

Les nombres complexes

C99 inclut un nouveau mot-clef pour manipuler les nombres complexes avec l'en-tête <complex.h> : complex. Celui-ci doit être précédé de float, double ou long double. Cet en-tête fournit également la macro I, dont le type peut changer en fonction de l'implémentation mais qui vaudra toujours $\sqrt{-1}$, ainsi que de nombreuses fonctions, comme par exemple le projeté, la racine carrée, le conjugué, le cosinus, le sinus, la tangente, la puissance, etc. Voici un exemple qui converti un complexe de sa forme cartésienne en sa forme polaire.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

#include <stdio.h>
#include <complex.h>

int main(void)
{
    double complex z = -4.4 + 3.3 * I;
    double radius = cabs(z);
    double argument = carg(z);

    double x = creal(z);
    double y = cimag(z);

    printf("cartesien(x, y) : (%f, %f)\n", x, y);
    printf("polaire(r, theta) : (%f, %f)\n", radius, argument);
    return 0;
}

Le mot-clef inline

Ce mot-clef permet de remplacer les macros dans un certain nombre de cas. En effet, nous avons vu dans le chapitre sur le préprocesseur que les macros peuvent provoquer des effets de bord indésirables et qu'elles ne vérifient absolument pas les types. En ajoutant inline devant la déclaration d'une fonction, on demande au compilateur de remplacer l'appel de la fonction par son corps, exactement comme les macros, mais tout en gardant les avantages des fonctions. Le code suivant illustre ce qui se passe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

inline unsigned int abs(int x)
{
    return (x < 0) ? -x : x;
}

int f(int x)
{
    int var = abs(x);
}

/* Le compilateur transformera le code en ceci : */

int f(int x)
{
    int var = (x < 0) ? -x : x;
}

Il y a tout de même quelques restrictions. Tout d'abord, il faut toujours définir une fonction inline dans un fichier d'en-tête (ce sont les seules fonctions que l'on doit définir dans un fichier d'en-tête, toutes les autres doivent être définies dans les fichiers sources). Enfin, rien ne force le compilateur à remplacer l'appel de la fonction par son corps. Ce mot-clef informe le compilateur mais ne le force absolument pas et il ne le fera que s'il le peut.

Le mot-clef restrict

Ce mot-clef permet de définir ce que l'on appelle des pointeurs restreints. Un pointeur définit comme restreint est le seul à pointer sur une certaine zone mémoire. Cela permet au compilateur de faire des optimisations, mais tout comme inline, le compilateur peut l'ignorer. Vous trouverez un tutoriel qui présente ce mot-clef et la solution qu'il apporte à certains problèmes de mémoire et de données ici.

Mélange déclaration / code

En C90, il est obligatoire de déclarer toutes ses variables au début du bloc d'instruction courant. En C99, cette règle est abolie et on peut déclarer ses variables où on veut. Pour certains, cela augmente la lisibilité du code puisqu'on déclare les variables juste avant leur utilisation. Pour d'autre, cela y nuit puisqu'on mélange les déclarations et le code. C'est à vous de choisir si vous utiliserez cette modification de la syntaxe ou non.

1
2
3
4
5
6
7

int main (void)
{
   printf ("Hello world!\n");
   int i = 10;
   printf ("i = %d\n", i);
   return 0;
}

Dernier point : maintenant on peut déclarer une variable temporaire pour la boucle for. Celle-ci disparait à la fin de la boucle.

1
2
3
4
5

for (size_t i = 0; i < 10; ++i)
{
    /* Ici, i existe. */
}
/* Ici, i n'existe plus. */

Les VLA

Les tableaux à taille variable, de l'anglais Variable Length Array, sont des tableaux dont la taille peut être définie par une variable. Jusqu'à maintenant, la taille devait impérativement être connue à la compilation, sinon il fallait passer par l'allocation dynamique. Maintenant, le code suivant est parfaitement valide.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

void foobar(size_t n)
{
    int array[n];
    for (size_t i = 0; i < n; ++i)
    {
         array[i] = i;
    }

    /* Faire quelque chose avec le tableau. */
}

Cette fonctionnalité marche aussi pour les prototypes et déclarations de fonctions, à condition que la variable qui sert de taille soit définie avant le tableau.

1
2

int foo(int n, int array[n]); // valide
int bar(int array[n], int n); // invalide

D'ailleurs, comme il n'est pas obligatoire d'écrire la taille d'un tableau "classique" lorsqu'on écrit le prototype d'une fonction, il n'est pas obligatoire de le faire dans le cas d'un VLA. Dans ce cas, il faudra juste mettre une étoile entre les crochets, comme dans l'exemple ci-dessous.

1

int foobar(int n, int array[*]);

Les limitations de ce mécanisme

La première limitation et sans doute la plus importante est l'endroit où doit être stocké le tableau. Nous avons vu dans le chapitre sur l'allocation dynamique la pile pour les objets automatiques et le tas pour les objets dynamiques. Puisque c'est un tableau, il devrait en toute logique être stocké sur la pile ; c'est d'ailleurs ce que fait GCC. Le problème est que rien n'est garanti ; il se peut qu'il soit stocké sur le tas. Vous devez donc faire attention à ne pas demander une trop grande taille pour ne pas faire déborder la pile.

La deuxième limitation est que la portée d'un VLA est limitée au fichier. Il est impossible d'utiliser dans un fichier un VLA définit dans un autre ou même de définir un VLA dont la taille est une variable externe. Les deux exemples suivants sont donc invalides.

1
2
3

extern int n;
int a[n];
extern int b[n];

Troisième limitation : il est impossible de déclarer un VLA statique. Enfin, quatrième limitation, il est impossible d'utiliser un tableau à taille variable dans une structure ou une union, sauf dans un cas bien particulier.

Tableau incomplet dans une structure

Il est possible de ne pas préciser la taille d'un tableau membre d'une structure, mais celui-ci doit absolument être le dernier champ de la structure en question. De plus, on ne pourra utiliser que des pointeurs sur cette structure alloués dynamiquement. Le code suivant (tiré de Developpez) illustre ce qu'on peut faire avec.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

#include <stdio.h>
#include <stdlib.h>

struct incomplet
{
    int n;
    int tab[];
};

int main (void)
{
    struct incomplet * a = malloc (sizeof(*a) + 3 * sizeof(int));
    if (a != NULL)
    {
        a->n = 3;
        for (size_t i = 0; i < a->n; i++)
        {
            a->tab[i] = i;
        }

        printf ("a->tab = { ");
        for (size_t i = 0; i < a->n; i++)
        {
            printf ("%d, ", a->tab[i]);
        }
        printf (" };\n");
        free (a), a = NULL;
    }

    struct incomplet * b = malloc (sizeof(*b) + 5 * sizeof(int));
    if (b != NULL)
    {
        b->n = 5;
        for (size_t i = 0; i < b->n; i++)
        {
            b->tab[i] = i;
        }

        printf ("b->tab = { ");
        for (size_t i = 0; i < b->n; i++)
        {
            printf ("%d, ", b->tab[i]);
        }
        printf (" };\n");
        free (b), b = NULL;
    }
    return 0;
}

1
2

a->tab = { 0, 1, 2,  };
b->tab = { 0, 1, 2, 3, 4,  };

La variable __func__

Cette variable contient le nom de la fonction courante. Elle est très utile pour débugger, par exemple pour connaître le nom de la fonction qui était en cours d'exécution quand le programme a planté.

Les nombres flottants

Le plus gros apport de C99 concerne sans aucun doute les nombres flottants. Alors que C90 laissait dans le flou, C99 supporte la norme IEEE 754. Comme le sujet est imposant, je vous renvoie au tutoriel de Maëlan sur le sujet. En plus, celui-ci vous formera de façon approfondie aux flottants et à leurs dangers, ainsi qu'à leur bonne utilisation en C. Que demande le peuple ?

Découvrir le C11

La suppression de gets

Cette fonction ressemble beaucoup à fgets sauf qu'elle n'est pas du tout sécurisée : si on dépasse la taille du buffer, elle continue à écrire comme si de rien n'était. C11 la supprime et la remplace par la fonction gets_s.

1

char * gets_s (char * restrict buffer, size_t nch);

Les structures et les unions anonymes

Jusqu'ici, lorsqu'on déclarait une structure ou une union, elle devait obligatoirement avoir un nom, même lors d'imbrications. Avec C11, ce n'est plus obligatoire. Ainsi, la structure du chapitre précédent deviendra comme ci-dessous.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13

enum {false, true};
typedef char bool;

struct cstring
{
    union buffer
    {
        struct { char * ptr; size_t capacity; };
        char array[32];
    };
    size_t length;
    bool is_in_ptr;
};

Dès lors, comment accéder aux membres de ces unions et structures anonymes ? Comme n'importe quel autre membre. Exemple ci-dessous.

1
2
3
4

struct cstring var;
var.ptr = "Hello world";
var.capacity = 42;
var.length = strlen(var.ptr);

Une nouvelle interface pour fopen

La fonction fopen peut désormais être ouverte avec un nouveau mode : "...x". Ce mode permet la création et l'ouverture du fichier en question. Si jamais le fichier existe déjà, alors la fonction échouera ; ce mode garanti donc de ne pas écraser de fichier. Ce mode se décline en quatre version.

  • "wx" : créé une fichier texte et permet d'écrire avec un accès exclusif à ce fichier.
  • "wbx" : pareil, sauf qu'on créé cette fois ci un fichier binaire.
  • "w+x" : créé un fichier texte avec accès exclusif et possibilité de lecture et d'écriture.
  • "wb+x" : de même, sauf qu'on créé un fichier binaire.

Enfin, sachez qu'un version plus sécurisée de fopen fait son apparition : fopen_s.

1

errno_t fopen_s (FILE ** pFile, const char * filename, const char *mode);

Les macros génériques

Le principe est simple : une macro générique pour les utilisateurs qui en interne appelle la bonne fonction selon le type de l'expression passée en paramètre. Un exemple sera plus parlant. Nous voulons faire la racine cubique d'un flottant. Nous avons donc cbrtf pour float, cbrt pour double et cbrtl pour long double. La macro se présentera donc comme ci-dessous.

1
2
3
4
5
6

#define cbrt(X) _Generic((X), long double : cbrtl, default : cbrt, float : cbrtf)(X)

/* Ou en version plus lisible : */
#define cbrt(X) _Generic((X), long double: cbrtl, \
                              default: cbrt, \
                              float: cbrtf)(X) \

Ainsi, en fonction de X, la bonne fonction sera appelée. Grâce à cette macro, on peut faire des choses amusantes, comme récupérer le type d'une variable (bien que ce soit rarement utile) ou bien le format pour printf.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

#define TYPE_OF(var) _Generic((var), \
    int : "Entier", \
    double : "Flottant", \
    default : "Inconnu")

#define printf_dec_format(x) _Generic((x),  \
    char : "%c", \
    signed char : "%hhd", \
    unsigned char : "%hhu", \
    signed short : "%hd", \
    unsigned short : "%hu", \
    signed int : "%d", \
    unsigned int : "%u", \
    long int: "%ld", \
    unsigned long int : "%lu", \
    long long int : "%lld", \
    unsigned long long int : "%llu", \
    float : "%f", \
    double : "%f", \
    long double : "%Lf", \
    char * : "%s", \
    void * : "%p")

#define print(x) printf(printf_dec_format(x), x)
#define printnl(x) printf(printf_dec_format(x), x), puts("")

Pour aller plus loin, je vous encourage à lire cet article en anglais qui présente de façon complète ce nouveau système de macros génériques.

Le mot-clef _Noreturn

Ce mot-clef se met pour préciser qu'une fonction ne retourne rien. Cela peut sembler bête à nos yeux puisque void sert déjà à indiquer que la fonction ne retourne aucune valeur. En fait, ce mot-clef servira au compilateur qui pourra optimiser le code ; sans lui, certaines optimisations sont impossibles.

1

 _Noreturn void foobar();

Vous trouverez de la documentation sur ce mot-clef sur open-std.org.

Améliorations des complexes

Pour faciliter la création de nombres complexes, C11 offre trois macros très simples d'utilisation : CMPLXF pour utiliser des parties réelles et imaginaires de type float, CMPLX pour double et CMPLXL pour long double.

1

double _Complex z = CMPLX(3, -2);

Vous pouvez en apprendre un peu plus sur le site d'IBM.


Des bibliothèques par milliers

Nous avons vu une petite partie de la bibliothèque standard durant ce cours. Non seulement nous n'avons pas tout vu, mais sachez qu'il existe en plus des milliers de bibliothèques dites tierces, c'est-à-dire des bibliothèques qui se rajoutent par-dessus la bibliothèque standard. Il en existe une quantité indénombrable qui permettent de tout faire ou presque. Toutes les montrer serait impossible et surtout inutile, donc nous en avons choisis quelques unes des plus connues. Nous ne ferons que les présenter ; si vous voulez les utiliser, il faudra apprendre par vous-mêmes en lisant la documentation, ou si vous avez de la chance, d'autres tutoriels.

  • Le reste de la bibliothèque standard
  • Les bibliothèques tierces

Le reste de la bibliothèque standard

La bibliothèque standard que propose le langage C est toute petite comparée à certains langages comme Java, et pourtant, nous sommes loin d'en avoir fait le tour. Ce petit sous-chapitre a pour but de vous montrer quelques-uns des fichiers d'en-têtes les plus utilisés en C. Sachez néanmoins que même en limitant le nombre de fichiers que nous allons montrer, il n'est pas possible d'être exhaustif. Voilà pourquoi je vous encourage à continuer vos recherches sur la bibliothèque standard par vous mêmes par la suite. Wikipédia et cplusplus.com sont de bons départs.

<assert.h> - Intercepter les erreurs de programmation

Ce fichier d'en-tête est tout simple : il ne contient qu'une seule macro : assert, qui signifie assertion. Qu'est ce qu'une assertion ? C'est une évaluation d'une condition qui, si jamais elle se révèle fausse, quitte le programme. Cela permet d'intercepter les erreurs de programmation mais pas les erreurs d'utilisation ou d'exécution. Voilà pourquoi les assertions ne sont utiles que lors des phases de test et sont désactivées lorsque cette phase est terminée.

Pour l'utiliser en C, il suffit de faire assert(condition) : si celle-ci est vraie, alors le programme continue normalement ; si elle est fausse, on affiche un message comme celui ci-dessous.

1

Assertion failed: expression, file f, line x

Voici un exemple de code en apparence correct mais qui comporte un problème (exemple tiré du site de Emmanuel Delahaye) : il affiche une valeur en trop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18

#include <stdio.h>

static void afficher (int const t[], size_t n)
{
    size_t i;
    for (i = 0; i <= n; i++)
    {
        printf ("%d", t[i]);
    }
    puts("");
}

int main (void)
{
    int tab[] = {1, 2, 3, 4};
    afficher (tab, sizeof(tab) / sizeof(*tab));
    return 0;
}

1

1 2 3 4 2

On décide donc de tester la validité de l'itérateur grâce à notre macro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

static void afficher (int const t[], size_t n)
{
    size_t i;
    for (i = 0; i <= n; i++)
    {
        assert(i < n);
        printf ("%d", t[i]);
    }
    puts("");
}

1
2
3
4
5
6

1   2   3   4 Assertion failed: i < n, file main.c, line 10

This application has requested the Runtime to terminate it in an unusual way.
Please contact the application's support team for more information.

Press ENTER to continue.

On se rend compte que l'itérateur a dépassé la taille maximale du tableau ; on comprend donc qu'il faut remplacer i <= n par i < n. En exécutant de nouveau le programme, l'affichage sera cette fois correct.

Une fois que l'on a fini de tester le programme et qu'on veut le distribuer, on doit désactiver toutes les assertions. Et au lieu de tout supprimer à la main, nous avons un moyen très simple : il suffit de définir la macro NDEBUG. En effet, si cette macro est définie, alors les assertions ne feront absolument rien. Et si jamais un problème est découvert, il suffit de supprimer cette macro pour réactiver les assertions.

<ctype.h> - Test sur les caractères

Cet en-tête définit tout un tas de macros permettant de savoir si le caractère à tester est un chiffre, une lettre, s'il est considéré comme un caractère imprimable, etc. Il fournit également deux macros permettant de convertir une minuscule en majuscule et vice-versa. Chaque macro fait appel à une fonction qui prend un caractère en paramètre et renvoie 0 si la condition testée et fausse, une autre valeur si elle est vraie. En voici la liste avec une brève explication.

  • isalnum : teste si le caractère est un chiffre, une majuscule ou une minuscule.
  • isalpha : teste si le caractère est une lettre de l'alphabet latin.
  • iscntrl : teste si le caractère est un caractère dit de contrôle, c'est à dire l'inverse d'un caractère dit imprimable. Parmi eux, on compte le retour à la ligne et la tabulation.
  • isdigit : teste si le caractère est un chiffre.
  • isgraph : teste si le caractère a une représentation graphique. Tous les caractères imprimables en ont une à l'exception de l'espace.
  • islower : teste si on a affaire à une minuscule.
  • isprint : teste si un caractère est imprimable.
  • ispunct : teste si le caractère est un signe de ponctuation.
  • isspace : teste si le caractère est un white-space character, à savoir ' ', '\t', '\n', '\v', '\f' ou '\r'.
  • isupper : teste si on a affaire à un majuscule.
  • isxdigit : teste si le caractère est un chiffre hexadécimal, c'est à dire s'il fait partie de cette liste : 0 1 2 3 4 5 6 7 8 9 a b c d e f A B C D E F.
  • tolower : convertit une lettre majuscule en minuscule ; ne fait rien si déjà en minuscule.
  • toupper : convertit une lettre minuscule en majuscule ; ne fait rien si déjà en majuscule.

<limits.h> - Connaître les limites des types entiers

Nous le savons, les types entiers ont une taille minimale et une taille maximale pour les valeurs qu'ils peuvent prendre. Nous le savons aussi, ces limites dépendent de l'implémentation. Ce fichier d'en-tête sert donc à fournir à tous les utilisateurs une liste de macros pour connaître les limites de chaque type, même si en interne les valeurs changent en fonction de l'environnement.

<float.h> - Limites des flottants et de ce qui les concerne

Cet en-tête donne les limites des types flottants, mais en plus de tout ce qui se rapporte à eux : la mantisse, l'exposant, l'arrondi utilisé, etc. Très utile pour une gestion poussée des flottants, surtout avec C99.

<stddef.h> - Définition d'alias

Ce fichier d'en-tête contient la définition de size_t et de ptrdiff_t. Nous connaissons le premier mais pas le second. C'est un alias qui permet de représenter la différence de deux pointeurs. Vous aurez sans doute moins l'occasion de l'utiliser que size_t mais il est utile de le connaître. Ce fichier est en tout cas très utile quand vous n'avez besoin de rien d'autre que ces deux alias.

<stdint.h> - Définitions d'entiers spéciaux et leurs limites (C99)

Il arrive que l'on veuille un entier qui fasse précisément 8 bits ou 32 bits par exemple. Comment faire puisque la taille d'un type peut varier selon l'implémentation ? C'est ce que propose ce fichier d'en-tête avec les intx_t (où x vaut 8, 16, 32 ou 64) et leur pendant non-signé uintx_t. Mais ce n'est pas tout. Il nous propose également un type intmax_t (ainsi que uintmax_t) qui est le plus grand entier que le système propose, ainsi qu'un type intptr_t (uintptr_t) qui permet de représenter une adresse ; on peut convertir une adresse void* en une adresse intptr_t et vice-versa sans perte.

Ce fichier contient également les limites de tous ces types, ainsi que celles d'autres types comme size_t ou ptrdiff_t par exemple.

Les bibliothèques tierces

Les bibliothèques pour 2D

SDL - Simple DirectMedia Layer

Logo de la SDL

La SDL est une bibliothèque multimédia employée pour créer des applications multimédias comme des émulateurs, des jeux vidéos 2D ou des lecteurs audio par exemple. Elle peut même être utilisé conjointement avec une bibliothèque 3D. Elle permet un accès de bas niveau à l'audio, au clavier, à la souris, au joystick, à la gestion du temps et bien d'autres choses encore. Elle est portable sur de très nombreux systèmes différents. Elle est gratuite et libre d'utilisation et de modification de par sa licence GNU LGPL version 2.

Voici une capture d'écran du jeu Mini Slug Project par joe78.

Aperçu du jeu Mini Slug Project

SFML - Simple and Fast Multimedia Library

Logo de la SFML

La SFML est une bibliothèque graphique originalement développée en C++ mais utilisable en C en autres. Elle fournit elle aussi l'accélération matérielle. Elle diffère de la SDL en ce qu'elle est divisée en modules : Système, Fenêtre, Graphique, Son et Réseau. Son plus grand avantage est qu'elle est développée par un français et donc possède une documentation dans la langue de Molière ainsi que de nombreux tutoriels. Ci-dessous, un exemple de jeu réalisable avec la SFML.

Arkanoid - The revival

Les bibliothèques pour la 3D

OpenGL - Open Graphics Library

OpenGL est une bibliothèque multimédia qui permet de développer des applications aussi bien en 2D qu'en 3D. Elle est portable, libre et gratuite. OpenGL est la version libre de l'avant-dernière version de GL (si par exemple GL en est à la version 2.3, OpenGL en est à la version 2.2). Cette bibliothèque permet de tirer profit de l'accélération 3D des cartes graphiques.

OpenGL est une bibliothèque bas-niveau qui ne traite que des rendus graphiques et ne gère pas les animations, le temps, les fenêtres, etc. C'est pour cette raison que OpenGL est utilisé conjointement avec une autre bibliothèque, comme la SDL, qui sert à créer une fenêtre dans laquelle OpenGL va travailler, à recevoir des entrées clavier, etc. Il faut quelques connaissances en mathématiques pour l'utiliser (pour les matrices par exemple).

Certains jeux connus utilisent OpenGL, comme Xonotic ou encore le fameux Quake 3.

Capture d'écran de Quake 4 qui utilise lui aussi OpenGL

DirectX

Logo de DirectX

DirectX est une collection de bibliothèques destinée au multi-média et particulièrement aux jeux vidéos. Elle est développée par Microsoft, elle ne marche donc que sur Windows et Xbox. DirectX permet de gérer non seulement la 3D, mais aussi la 2D, les entrées (clavier, souris, joysticks), le réseau, le son, les vidéos et bien d'autres choses. C'est la bibliothèque favorite de beaucoup de studios, et c'est l'une des raisons pour laquelle la majorité des jeux vidéos commercialisés ne sont jouables que sous Windows.

Il faut cependant faire attention aux différentes versions de DirectX, les toutes dernières n'étant plus supportées par les systèmes les plus anciens : DirectX 10 n'est supportée que par Vista et Seven.

Les bibliothèques pour le son

Logo de FMOD

FMOD est une bibliothèque très puissante de gestion du son, utilisée par de très nombreux jeux commerciaux. Plus de 10 systèmes d'exploitations sont supportés, de Windows à GNU/Linux, en passant par la Wii et la PS3. FMOD est gratuite pour une utilisation personnelle et non commerciale. Si l'envie vous prend de vendre un logiciel qui utilise FMOD, vous devrez dépenser quelques milliers de dollars.

Bibliothèques pour faire des applications graphiques

API Windows

Logo de l'API Windows

L'API Windows est conçue pour les langages de programmation C et C++ et est la manière privilégiée pour une application d'interagir avec les systèmes d'exploitation Windows. Une API, comme son nom l'indique, est une interface de programmation : elle contient un ensemble de fonctions bas niveau permettant de programmer des applications haut niveau. Elle permet d'interagir avec Windows de manière poussée : récupération des informations sur le système (version, nombre de processeurs, taille de la RAM, etc), création de fenêtres et d'utilitaires (pratique car les programmes ainsi créés ressembleront aux autres programmes Windows), accès au shell, aux services réseaux, etc.

Il existe plusieurs version de cette bibliothèque :

  • Win16 était la première API pour les versions 16-bits du système.
  • Win32 est la version 32-bits de l'API pour les systèmes plus récents.
  • Win32s est une extension de Win32 pour les systèmes Windows 3.x qui a été introduite comme sous-ensemble de Win32. Le "s" est pour sous-ensemble (subset en anglais).
  • Win32 pour les éditions 64 bits précédemment appelée Win64 est la version pour les ordinateurs 64-bits, avec les versions Windows XP Professional x64 Edition pour les processeurs x86-64 ainsi que Windows XP 64-bit Edition et Windows Server 2003 pour les processeurs Itanium. Cette version apporte juste le support pour ces deux nouvelles plateformes.

Xlib

Logo de Xlib

La bibliothèque Xlib réalise l'interfaçage entre le protocole X et les applications X. Elle contient des fonctions de bas niveau pour interagir avec un serveur X. Peu d'applications utilisent la Xlib directement ; en général, elles exploitent d'autres bibliothèques qui reposent sur la Xlib pour fournir des éléments d'une interface graphique. En utilisant Xlib, nous pouvons gérer les tâches suivantes :

  • la gestion des fenêtres ;
  • le texte : fontes, tailles, styles, etc ;
  • le graphisme : fonctions de dessin 2D ;
  • la couleur ;
  • les communications avec le serveur ;
  • la gestion des évènements et des erreurs (coupures réseau, etc ...) ;
  • et bien d'autres choses encore.

X11 ou simplement X est un environnement graphique de type « fenêtré » qui gère l'interaction homme-machine par l'écran, la souris et le clavier de certains ordinateurs en réseau. Les célèbres bibliothèques GTK et Qt sont basées sur X.

Gtk+

Logo de Gtk+

GTK+ est une bibliothèques permettant de réaliser des interfaces graphiques. Cette bibliothèque a été développée originellement pour les besoins du logiciel de traitement d'images The GIMP. GTK+ est maintenant utilisé dans de nombreux projets, dont les environnements de bureau GNOME, Xfce et ROX. GTK+ est un projet libre (licence GNU LGPL 2.1), gratuit et multiplate-forme.

Autres bibliothèques

GMP

GMP est une bibliothèque de calcul précis sur des très grands nombres comme $4242^{6014}$, le tout rapidement. Les principaux domaines d'applications de GMP sont la recherche et les applications en cryptographie, les logiciels applicatifs de sécurité pour Internet et les systèmes de calcul formel. Par exemple, le célèbre chiffrement RSA utilise la bibliothèque GMP afin de gérer de grands nombres.

GMP est notamment utilisée dans le logiciel de calcul formel Maple à partir de la version 9 et Mathematica depuis la version 5.


Notes sur le langage C

Table des matières http://emmanuel-delahaye.developpez.com/tutoriels/c

  • Page 1
    • I. C'est quoi l'algorithmique ?
    • II. Déclarations ? Définitions ?
      • II-A. Déclaration
      • II-B. Définition
    • III. Langage C ? Fonctions systèmes ? Je suis perdu !
      • III-A. Domaine couvert par le langage C
      • III-B. Fonctions système
      • III-C. Bibliothèques tierces publiques
      • III-D. Bibliothèques tierces privées
    • IV. C, UNIX, POSIX ?
    • V. Production du code exécutable
    • VI. pile, tas ? C'est quoi ?
    • VII. Le mode plein écran
      • VII-A. Installation de ansi.sys sous Windows XP
  • Page 2
    • VIII. Se procurer la norme du langage C (C99)
    • IX. C'est quoi ce 'static' ?
  • Page 3
    • X. Le type retourné par main()
      • X-A. Historique
    • XI. C'est quoi un prototype ?
    • XII. Bibliothèques de fonctions
      • XII-A. Bibliothèque statique
      • XII-B. Bibliothèque dynamique
    • XIII. Pourquoi ma fonction ne modifie pas ma variable ?
    • XIV. Les fonctions à nombre d'arguments variable
      • XIV-A. Introduction
      • XIV-B. Interface
      • XIV-C. Implémentation
      • XIV-D. Conclusion
  • Page 4
    • XV. size_t, c'est quoi ?
    • XVI. Données
      • XVI-A. Initialisation
    • XVII. Structures
      • XVII-A. Structures visibles
      • XVII-B. Structures opaques
      • XVII-C. Pseudonymes (ou alias)
    • XVIII. Variables globales
      • XVIII-A. Nommage
      • XVIII-B. Organisation
      • XVIII-C. Définition
      • XVIII-D. Déclaration
      • XVIII-E. Utilisation
    • XIX. Champs de bits
    • XX. Bien utiliser const
      • XX-A. Objets simples
      • XX-B. Pointeurs
      • XX-C. Usage
  • Page 5
    • XXI. Les pointeurs démythifiés !
      • XXI-A. Introduction
      • XXI-B. Définition
        • XXI-B-1. Pointeur sur objet
        • XXI-B-2. Pointeur de fonction
          • XXI-B-2-a. Remarques importantes
      • XXI-C. Du bon usage des pointeurs
    • XXII. Les pointeurs, ça sert à quoi ?
      • XXII-A. A quoi ça sert?
        • XXII-A-1. Petit rappel.
        • XXII-A-2. Comment faire ?
    • XXIII. Un tableau n'est pas un pointeur ! Vrai ou faux ?
    • XXIV. Passer un tableau à une fonction
      • XXIV-A. Introduction
      • XXIV-B. Tableau à une dimension
      • XXIV-C. Tableau à n dimensions
    • XXV. Retourner un tableau
    • XXVI. char* char**
      • XXVI-A. Introduction
      • XXVI-B. Chaine de caractères
      • XXVI-C. Le type char *
      • XXVI-D. Le type char **
    • XXVII. Initialisation des tableaux de caractères
    • XXVIII. Qu'est-ce qu'une chaine litterale ?
    • XXIX. Bien utiliser malloc()
      • XXIX-A. Suppression du cast
        • XXIX-A-1. Compilateur non ISO
        • XXIX-A-2. Compilateur C++
      • XXIX-B. Déterminer la taille sans le type
    • XXX. Bien utiliser realloc()
    • XXXI. Comment créer un tableau dynamique à deux dimensions ?
  • Page 6
    • XXXII. Saisie de données par un opérateur (stdin)
      • XXXII-A. Introduction
      • XXXII-B. fgetc(), getc(), getchar()
      • XXXII-C. gets()
      • XXXII-D. scanf()
      • XXXII-E. fgets()
      • XXXII-F. Ressources
      • XXXII-G. Comment fonctionne fgetc(stdin) alias getchar()
        • XXXII-G-1. Comportement visible.
        • XXXII-G-2. Comportement interne.
        • XXXII-G-3. Quelques expérimentations.
    • XXXIII. Les fichiers
      • XXXIII-A. Introduction
      • XXXIII-B. Texte ou binaire ?
        • XXXIII-B-1. Fichier texte
        • XXXIII-B-2. Fichier binaire
        • XXXIII-B-3. Modes d'ouverture d'un fichier
        • XXXIII-B-4. Lecture d'un fichier
          • XXXIII-B-4-a. fgetc(), getc()
          • XXXIII-B-4-b. fread()
          • XXXIII-B-4-c. fscanf()
          • XXXIII-B-4-d. fgets()
            • XXXIII-B-4-d-i. Exemple d'utilisation
            • XXXIII-B-4-d-ii. Explication
            • XXXIII-B-4-d-iii. Critique de cet exemple
            • XXXIII-B-4-d-iv. Détection d'une erreur
            • XXXIII-B-4-d-v. Gestion des fins de ligne
            • XXXIII-B-4-d-vi. Exemple amélioré avec détection de la fin de lecture
            • XXXIII-B-4-d-vii. Explication
        • XXXIII-B-5. Écriture dans un fichier
          • XXXIII-B-5-a. fputc(), putc()
          • XXXIII-B-5-b. fwrite()
          • XXXIII-B-5-c. fprintf()
          • XXXIII-B-5-d. fputs()
        • XXXIII-B-6. Bien utiliser les formats de données
          • XXXIII-B-6-a. Format orienté texte
          • XXXIII-B-6-b. Format orienté binaire
      • XXXIII-C. Supprimer un enregistrement dans un fichier binaire
      • XXXIII-D. En guise de conclusion
    • XXXIV. Pourquoi fflush (stdout) ?
  • Page 7
    • XXXV. <time.h> : La gestion du temps
      • XXXV-A. Introduction
      • XXXV-B. Usage
        • XXXV-B-1. Lire l'heure courante
        • XXXV-B-2. Afficher l'heure courante
    • XXXVI. <time.h> : bien utiliser difftime()
    • XXXVII. rand(), srand()... j'y comprends rien...
  • Page 8
    • XXXVIII. Du bon usage de qsort()
      • XXXVIII-A. Description de la fonction qsort()
        • XXXVIII-A-1. Introduction
        • XXXVIII-A-2. Interface
          • XXXVIII-A-2-a. Interface de la fonction de comparaison
        • XXXVIII-A-3. Comportement
          • XXXVIII-A-3-a. Comportement de la fonction de comparaison
      • XXXVIII-B. Usage de la fonction qsort()
        • XXXVIII-B-1. Tri d'un tableau d'entiers
        • XXXVIII-B-2. Tri d'un tableau de chaînes
        • XXXVIII-B-3. Tri d'un tableau de structures
        • XXXVIII-B-4. Tri de nombres réels
    • XXXIX. Les identificateurs réservés
    • XL. Bien gérer la portée des objets et des fonctions
      • XL-A. Fonctions
      • XL-B. Objets
        • XL-B-1. Définition hors d'un bloc
        • XL-B-2. Définition dans un bloc
        • XL-B-3. Masquage (Shadowing)
    • XLI. Du bon usage de assert()
      • XLI-A. Exemple d'utilisation
    • XLII. Comportement indéfini
    • XLIII. Les item-lists
      • XLIII-A. Introduction
      • XLIII-B. Mise en oeuvre
  • Page 9
    • XLIV. Borland C : "floating point formats not linked"
    • XLV. Code standard ? Code portable ? Je suis perdu !
      • XLV-A. "standard" ?
      • XLV-B. "portable" ?
        • XLV-B-1. portabilité absolue
        • XLV-B-2. portabilité relative
      • XLV-C. Bon usage
    • XLVI. Enregistrer une structure
      • XLVI-A. Le format binaire
      • XLVI-B. Le format texte

Ce document est un peu disparate. En effet, il contient une multitude d'articles plus ou moins longs qui sont souvent des réponses que j'ai pu apporter à des questions souvent posées sur les forums.

Je me suis efforcé ensuite de les organiser en chapitres de la façon suivante :

  • Généralités ;
  • Éléments du langage ;
  • Fonctions ;
  • Types et Variables ;
  • Pointeurs, tableaux, chaînes ;
  • Entrées / sorties ;
  • Bibliothèque standard ;
  • Codage ;
  • Divers.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • I. C'est quoi l'algorithmique ?
  • II. Déclarations ? Définitions ?
    • II-A. Déclaration
    • II-B. Définition
  • III. Langage C ? Fonctions systèmes ? Je suis perdu !
    • III-A. Domaine couvert par le langage C
    • III-B. Fonctions système
    • III-C. Bibliothèques tierces publiques
    • III-D. Bibliothèques tierces privées
  • IV. C, UNIX, POSIX ?
  • V. Production du code exécutable
  • VI. pile, tas ? C'est quoi ?
  • VII. Le mode plein écran
    • VII-A. Installation de ansi.sys sous Windows XP

I. C'est quoi l'algorithmique ?

C'est l'art de traduire un comportement en phrases simples et claires.

Surveiller la température.
Si elle dépasse la consigne, actionner une alarme.

Ces phrases sont ensuite traduites en 'pseudo-code' qui est une sorte de langage de description des comportements (ressemble au Pascal) à base d'actions et de structures de code comme IF-ELSE-ENDIF, SELECT-CASE, REPEAT-UNTIL, WHILE etc.

Sélectionnez
DO
   temperature := read_temperature()
   IF temperature > threshold
      alarm (ON)
   ENDIF
FOREVER

Ce pseudo-code est ensuite traduit facilement en langage d'implémentation (par exemple en C).

Sélectionnez
{
   for (;;)
   {
      int temperature = read_temperature();
      if (temperature > threshold)
      {
         alarm (ON);
      }
   }
}

Pour plus d'informations, consultez les Cours & Tutoriels sur l'algorithmique disponibles sur Developpez.com.

II. Déclarations ? Définitions ?

Il y a beaucoup de confusions sur les termes définition et déclaration en C. Voici un petit article qui va s'efforcer de mettre les choses au point.

II-A. Déclaration

Une déclaration permet l'utilisation d'un objet ou d'une fonction. Elle doit être préalable à l'utilisation.

Exemples avec un objet x de type int :

Sélectionnez
extern int x;
<...>
{
   x = 2;
}

ou

Sélectionnez
int x;

<...>
{
   x = 2;
}

ou

Sélectionnez
{
   int x;
<...>
   x = 2;
}

Voici des exemples de déclarations de fonctions :

Sélectionnez
int f();

extern int g(void);

static int h();

static int i(int a)
{
}

int j(char *b)
{
}

II-B. Définition

Une définition est l'endroit où l'objet ou la fonction sont réellement définis. De l'espace mémoire est alloué à l'objet, les instructions sont fournies à la fonction (entre des {}). Notons que par conséquent, une définition est aussi une déclaration implicite. Il convient donc d'être prudent sur l'emploi des termes. Voici des définitions d'objets :

Sélectionnez
int a;
static float b[12];
{
   struct xxx c;
   static long d;
}

ou de fonction :

Sélectionnez
int f()
{
}

int g(void)
{
}

La dernière forme est une définition avec déclaration implicite sous forme de prototype.

III. Langage C ? Fonctions systèmes ? Je suis perdu !

Sur un forum je pose une question sur le langage C et on me répond «va voir sur un forum consacré à ton système». M'enfin, je programme en C, c'est quoi ce cirque ?

III-A. Domaine couvert par le langage C

Le langage C, tel qu'il est défini par la norme, est un ensemble de règles d'écriture (syntaxe, sémantique) définissant les éléments du langage, et un ensemble de fonctions regroupées sous le terme générique de 'bibliothèque d'exécution du [langage] C'.

Les domaines couverts par la bibliothèque sont :

  • les flux d'entrée/sorties ;
  • les traitements de chaînes ;
  • les conversions chaîne/binaires ;
  • les fonctions mathématiques ;
  • les algorithmes génériques (tri, recherche) ;
  • la gestion du temps ;
  • (d'autres qui me reviendront plus tard…).

On constate donc qu'un programme C standard permet d'entrer des données à partir de la ligne de commande (paramètres de main()) ou d'un flux entrant (stdin, fichier en lecture), de les traiter 'silencieusement', ou avec une trace vers un flux sortant (stdout, stderr ou un fichier en écriture) et de sortir des données vers une un flux sortant selon le schéma bien connu.

Sélectionnez
+--------+   +------------+   +--------+
| entrée |-->| traitement |-->| sortie |
+--------+   +------------+   +--------+

Exemples typiques :

  • compilateur ;
  • générateur de code ;
  • convertisseur wav -> mp3 ;
  • etc.

Si on cherche d'autres domaines d'applications comme :

  • gestion de l'écran en mode texte ;
  • lecture du clavier sans attente ;
  • impression ;
  • port série ;
  • réseau ;
  • écran graphique ;
  • programmation évènementielle ;
  • programmation multitâches ;
  • interface graphique ;
  • souris ;
  • etc.

il va falloir utiliser des ressources externes au langage C. Bien que ces ressources disposent (entre autres), d'une interface leur permettant d'être appelées par un programme écrit en C, elles ne font pas partie du langage C.

On distingue principalement 3 types de ressources :

  • les fonctions fournies par le système ;
  • les fonctions des bibliothèques publiques (gratuites, payante) ;
  • les fonctions des bibliothèques privées (personnelles, entreprises).

III-B. Fonctions système

Rappelons qu'un système est un logiciel lancé par la machine au démarrage et qui prend en charge la gestion des ressources matérielles, ainsi que la surveillance de différents événements. D'autre part, il fourni un certain nombre de fonctions utilisables dans les applications, ainsi que le moyen de charger et lancer (exécuter) une ou des applications.

L'ensemble des interfaces des fonctions système utilisables pour développer des applications est décrit dans un document appelé API (Application Programming Interface). Ce document précise pour chaque fonction :

  • identificateur ;
  • paramètres ;
  • retour ;
  • comportement.

Pour des raisons propres à chaque architecture, l'interface est souvent matérialisée par un 'TRAP' ou une interruption logicielle que l'on ne peut appeler qu'en assembleur. Par exemple en sur un PC/x86, l'interruption BIOS 'Vidéo' :

Sélectionnez
MOV AH, 09h
   MOV AL, character
   MOV BH, 00h
   MOV BL, attributes
   MOV CX, 01h
   INT 10h

ou avec des extensions de très bas niveau comme par exemple ceci en Borland C :

Sélectionnez
#if defined (__BORLANDC__)
#include <dos.h>
#else
#error Undefined for this platform
#endif

<...>

/* ---------------------------------------------------------------------
   VIDEO_putch()
   ---------------------------------------------------------------------
   écriture d'un caractère a la position courante du curseur avec
   la couleur specifiee
   ---------------------------------------------------------------------
   E : caractère (0-255)
   E : couleur texte
   E : couleur fond
   S :
   --------------------------------------------------------------------- */
void VIDEO_putch (int c, eCOU ct, eCOU cf)
{
#if defined (__BORLANDC__)
   union REGS reg;

   /* caractere a écrire */
   reg.h.al = (uchar) c;

   /* putch avec attribut */
   reg.h.ah = 0x09;

   /* page 0 */
   reg.h.bh = 0;

   /* attributs 08h : clignotement */
   reg.h.bl = (uchar) (ct | ((cf & ~0x08) << 4));

   /* pas de repetitions */
   reg.x.cx = 1;

   /* Bios Video */
   int86 (0x10, &reg, &reg);
#endif
}

Afin de faciliter l'appel à partir de différents langages de développement, chaque implémenteur de compilateur (C, C++, Pascal, Ada etc.) ou d'interpréteur (BASIC, Python, Ruby etc.) fournit une interface dans son langage. Les fonctions systèmes apparaissent donc comme une extension dudit langage.

Chaque système dispose de sa propre API. Mais certains systèmes offrent des API définies selon la norme POSIX, ce qui tend à normaliser au moins une partie des API.

III-C. Bibliothèques tierces publiques

Il est aussi possible d'utiliser des fonctions fournies par des bibliothèques publiques gratuites ou payantes selon les besoins et les licences requises. Ces bibliothèques sont le plus souvent indépendantes de la plate-forme. On peut citer (interface C) :

  • GTK+ (GUI) ;
  • SDL (Graphisme 2D simple) ;
  • Fmod (Multimédia) ;
  • MySQL (SGBD) ;
  • etc.

III-D. Bibliothèques tierces privées

Chaque développeur peut, pour lui-même, ou au sein de son entreprise, développer une bibliothèque de fonctions 'métier' qui lui facilitent la réalisation d'applications spécialisées. Il est courant que ces bibliothèques soient partagées dans l'entreprise.

Certaines de ces bibliothèques peuvent ensuite devenir publiques si elles peuvent intéresser d'autres personnes et si la licence le permet.

IV. C, UNIX, POSIX ?

Le langage C n'a à voir ni avec UNIX ni avec POSIX.

UNIX est une norme de spécification de système (actuellement UNIX 03) gérée par l'Open Group qui définit un système indépendamment de la machine sur lequel il tourne. Elle s'appuie, entre autre, sur la spécification des fonctions systèmes définie par POSIX.1 sous l'égide de l'IEEE.

Cette norme (POSIX.1) comprend à la fois la description d'un certain nombre de fonctions systèmes, et d'au moins deux types d'interfaces :

  • le mode commande (shell) pour l'utilisateur de la machine ;
  • une API utile aux programmeurs d'applications.

L'API est décrite sous la forme d'interfaces de fonctions appelables directement en langage C (C99 pour les dernières spécifications de POSIX.1). Les fonctions standards du C sont reprises telles quelles par POSIX.1.

Ce sont ces nombreux emprunts au langage C qui ont créés la confusion entre langage C et POSIX…

Cette API est implémentée par une bibliothèque (.a, .so, .lib, .dll, ...) et des fichiers d'entête (.h) livrés avec les systèmes compatibles POSIX.1 (la plupart des unixoïdes, mais aussi certains Windows).

La spécification complète de POSIX.1 est disponible ici (s'inscrire, c'est gratuit et sans danger).

Les constructeurs de systèmes et de compilateurs s'efforcent de suivre tout ou partie de ces normes, ce qui permet une portabilité importante dans des domaines qui ne sont pas couverts par la norme du langage C comme :

  • la gestion des répertoires ;
  • la gestion des processus (process) ;
  • la gestion des tâches (threads) ;
  • la gestion des réseaux (sockets) ;
  • etc.

V. Production du code exécutable

Il existe de nombreuses implémentations du langage C. Certaines sont des interpréteurs, mais la plupart sont des compilateurs.

Compiler un programme consiste à vérifier le code source, puis à le traduire en langage machine de façon à en faire un fichier exécutable qui pourra ensuite être exécuté par la machine.

Les détails de production du code dépendent de l'implémentation, c'est à dire de la machine, du système, des outils utilisés etc. Cependant, il existe une procédure générale commune à toutes ces implémentations :

  • production de modules de code machine intermédiaires par compilation individuelle des codes sources. Il manque les références externes ;
  • production du code machine exécutable par résolution des références externes (liens) entre les modules intermédiaires, et l'éventuelle ajout de bibliothèques de fonctions selon les besoins.

Pour cela, on utilise successivement deux outils :

  • le compilateur (compiler), autant de fois que nécessaire, selon le nombre de fichiers sources à traiter ;
  • l'éditeur de lien (linker), une fois pour produire l'exécutable.

VI. pile, tas ? C'est quoi ?

Le langage C définit 3 zones de stockage pour les objets (aussi appelés variables)

  • la mémoire statique ;
  • la mémoire allouée ;
  • la mémoire automatique.

1 - Les objets définis hors des fonctions et les objets définis avec le mot clé static sont placés en mémoire statique. Leur durée de vie est celle de l'exécution du programme. Ils existent et sont initialisés (à 0 par défaut) avant même le lancement de main().

2 - Les objets définis avec les fonctions malloc(), calloc() et realloc() sont placés en mémoire allouée (appelée heap ou tas sur certaines implémentations). Leur durée de vie est contrôlée par le programme. Ils existent dès que l'appel de *alloc() retourne une valeur différente de NULL et cessent d'exister dès que free() est appelé avec la valeur retournée par *alloc().

3 - Les objets définis dans un bloc, sans qualificateur 'static', sont placés en mémoire automatique (appelée stack ou pile sur certaines implémentations). Leur durée de vie est celle du bloc dans lequel ils sont définis.

VII. Le mode plein écran

En C standard, la notion d'écran (et plus généralement de matériel) n'existe pas. Une application écrite en C est censée tourner sur une machine abstraite dont les interfaces matérielles sont vues comme des flux d'entrée ou de sortie. Ceci dans un souci de portabilité, qui est une des raisons d'être du langage C.

Parmi ces flux, il en existe 3 qui sont ouverts par défaut :

  • stdin : entrée standard ;
  • stdout : sortie standard ;
  • stderr : sortie erreur.

Une application écrite en C peut être recompilée pour une multitude de plate-formes (qui diffèrent selon le processeur, l'architectures, le système, etc.) pour lesquelles stdin, stdout et stderr seront implémentés de manières différentes.

Sur un modem, par exemple, ces flux seront connectés à un port série permettant de brancher une console par laquelle on pourra passer, par exemple, des commandes AT.

Le but est d'instaurer un dialogue 'interactif' dans lequel l'application montre qu'elle attend une commande de l'opérateur à l'aide d'un 'prompt' ou 'invite de commande' :

Sélectionnez
modem> _

L'utilisateur peut alors passer une commande de numérotation :

Sélectionnez
modem> ATD0123456789
_

et le modem répond :

Sélectionnez
CONNECTED AT 9600 BAUDS

Il passe alors en mode transmission de données, et il signale qu'il n'attend plus de commandes par l'absence de prompt.

De même, sur un PC sous Windows ou unixoide, il est possible d'ouvrir une console localement ou de se brancher localement ou à distance sur la machine via une console (gestion, application, debug...). L'application ne verra pas la différence et continuera à attendre ses commandes de stdin et à envoyer ses réponses à stdout et/ou stderr.

L'avantage d'un tel système est qu'il exige très peu de la console, qui peut se réduire à un simple terminal série, voire à une imprimante série s'il ne s 'agit que de traces sur stdout ou stderr (utile pour mettre au point une application graphique, par exemple).

La gestion plein écran est une autre façon de concevoir la relation entre l'homme et la machine (IHM). En effet, on peut reprocher à l'interface console 'interactive', un certain manque de convivialité (largement compensé par le développement de langages 'scripts' extrêmement puissants), rendant les applications difficiles d'accès à un public non informaticien (direction, comptables, secrétaires, achats, commerciaux etc.)

C'est pour cela qu'il a été développé la notion d'interface 'plein écran' qui permet d'améliorer l'ergonomie :

  • effacement de l'écran ;
  • placement absolu du curseur ;
  • contrôle de la couleur du texte et du fond.

Malheureusement, il n'a jamais été prévu de norme internationale régissant cette gestion d'écran (probablement parce que faisant trop partie du domaine applicatif, chaque constructeur voulant tirer parti des performances de son matériel et de son logiciel).

Certaines pratiques et normes de fait se sont toutefois imposées.

  • Basées sur les ressources de la machine :
    • PC/MS-DOS : Borland et sa bibliothèque conio (Turbo Pascal, C) pour la console intégrée. Windows bénéficie d'un portage réduit de conio pour MinGW (voir la documentation) ;
    • Unixoides : curses et ncurses. (consoles locales).
  • Basées sur des ressources console :
    • Commandes ANSI (VT-100) ;
    • Vidéotex (Minitel) ;
    • HTML (Browser).

Je recommande la bibliothèque PDCurses qui est une version portable et multi-plateforme de [n]curses et qui permet facilement de gérer les entrées/sorties directes à la console, et ce soit d'une manière basique à-la-conio, ou d'une manière plus avancée. Il existe de nombreux tutoriels sur le net.

Mention spéciale pour 'termcaps' qui est une bibliothèque multi-plateforme censée s'adapter à quasiment tous les terminaux supportant des séquences d'échappement.

VII-A. Installation de ansi.sys sous Windows XP

A ma connaissance, il n'y a pas de possibilité d'installer un interpréteur de séquences ANSI pour cmd.exe.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • VIII. Se procurer la norme du langage C (C99)
  • IX. C'est quoi ce 'static' ?

VIII. Se procurer la norme du langage C (C99)

La norme du C est définie par l'ISO. On trouve l'original sur le site de l'ISO, mais elle est très chère. Elle est aussi reprise par l' ANSI à l'identique, pour la somme raisonnable de 20 USD.

Il est possible de télécharger gratuitement le dernier draft de la norme (N1256) ici : (C99). Ce document est complet et intègre C99, TC1 et TC2 (Technical Corrigendum 1 et 2). C'est probablement le dernier draft du document définitif de l'ultime norme du C qui ne devrait plus évoluer, le comité étant en sommeil.

En fait, le comité continue de travailler, et une nouvelle version du langage C (C1x) est en cours d'élaboration. En voici le dernier draft : n1336.

Il existe un document quasiment officiel qui commente la norme : Le 'Rationale'. Très utile pour trouver des explications complémentaires.

IX. C'est quoi ce 'static' ?

static est un qualificateur qui a plusieurs significations selon le contexte :

  • devant une déclaration de fonction :
Sélectionnez

static int f(void)

Limite la portée de la fonction à l'unité de compilation courante.

  • devant une variable définie hors de tout bloc (dite 'de portée globale') :
Sélectionnez

static int x;

Limite la portée de la variable à l'unité de compilation courante.

  • devant une variable définie dans une bloc (dite 'de portée locale') :
Sélectionnez

int counter(void)
{
   static int x;
   x++;
   return x;
}

Place la variable dans la mémoire statique, la rendant persistante. Usage rarissime (quick'n dirty).

A éviter, surtout pour du code réutilisable (bibliothèque).

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • X. Le type retourné par main()
    • X-A. Historique
  • XI. C'est quoi un prototype ?
  • XII. Bibliothèques de fonctions
    • XII-A. Bibliothèque statique
    • XII-B. Bibliothèque dynamique
  • XIII. Pourquoi ma fonction ne modifie pas ma variable ?
  • XIV. Les fonctions à nombre d'arguments variable
    • XIV-A. Introduction
    • XIV-B. Interface
    • XIV-C. Implémentation
    • XIV-D. Conclusion

X. Le type retourné par main()

Bien que main() soit censé retourner un int, on voit quelquefois écrit

Sélectionnez

void main (void)
{
}

Qu'en est-il exactement ?

D'après la définition du langage C, dans un programme conforme, main() doit retourner int. D'ailleurs un compilateur comme Borland C 3.1 en mode ANSI refuse void main() (error). Dans les mêmes conditions, gcc qui émet un avertissement (warning).

X-A. Historique

Dès l'apparition du langage C, une des formes canoniques de main() était :

Sélectionnez

main()
{
   return 0;
}

l'autre étant la forme qui permet de récupérer les arguments de la ligne de commande.

Il faut bien comprendre qu'à cette époque, une fonction définie sans type de retour explicite, retournait un int (c'est toujours le cas en C90, mais plus en C99 où le type doit être explicite). Le mot clé 'void' n'existait pas.

Il n'y avait donc aucune raison d'utiliser une forme void main().

Ensuite, est venue la normalisation du langage C. (1989 ANSI, 1990 ISO). Dans le texte, les deux formes canoniques sont décrites :

Sélectionnez

int main (void)

et :

Sélectionnez

int main (int argc, char **argv)

Il est précisé en remarque (dans la partie non normative) qu'il existe d'autres formes sans autres précisions. Elles ne font donc pas partie de la norme, leur comportement est donc indéfini dans le cadre d'un programme respectueux de la norme (dit 'strictement conforme').

XI. C'est quoi un prototype ?

Lorsqu'on défini une fonction :

Sélectionnez

int f (char *s)
{
  ...
}

on définit en même temps son interface, à savoir :

  • son nom (ici , f) ;
  • son type de retour (ici, int) ;
  • ses paramètres (ici, s, de type pointeur sur char).

Cet interface est aussi appelé prototype intégré. Il permet une utilisation de la fonction si l'appel est placé après la définition (il n'y a pas de raison de faire autrement, sauf cas exceptionnels et souvent douteux...) :

Sélectionnez

int f (char *s)
{
  ...
}

int main (void)
{
   int x = f("hello");
}

Maintenant, dans le cas de compilation séparée, on doit 'détacher' le prototype et le placer dans un fichier qui sera inclus à la fois dans le fichier d'implémentation de la fonction et dans le ou les fichiers qui l'utilisent.

Sélectionnez

/* f.h */
int f (char *s);
Sélectionnez

/* f.c */
#include "f.h"
int f (char *s)
{
  ...
}
Sélectionnez

/* main.c */
#include "f.h"

int main (void)
{
   int x = f("hello");
}

Pour compléter, il est fortement recommandé de protéger les fichiers .h contre les inclusions multiples avec une garde (guard) :

Sélectionnez

#ifndef H_F
#define H_F
/* f.h */

int f (char *s);

#endif

Détails dans cet article ici

XII. Bibliothèques de fonctions

Une bibliothèque (library) est une collection de fonctions mise à la disposition des programmeurs. Elle se compose d'une interface matérialisée par un ou plusieurs fichiers d'entêtes (.h), et d'un fichier d'implémentation qui contient le corps des fonctions (.lib, .a, .dll, .so etc.) sous forme exécutable.

Il est aussi possible à un programmeur de 'capitaliser' son travail en réalisant des fonctions réutilisables qu'il peut ensuite organiser en bibliothèques. Cette pratique est courante et encouragée. La création physique d'une bibliothèque est assez simple si on respecte quelques règles de conception, comme l'absence de globales, la souplesse et l'autonomie (voir l'article Comment construire sa bibliothèque). Elle nécessite un outil spécialisé (librarian) généralement livré avec toute implémentation du langage C.

Une bibliothèque peut être statique ou dynamique (partagée).

XII-A. Bibliothèque statique

Une bibliothèque à édition de lien statique (.lib, .a etc.) est liée à l'application pour ne former qu'un seul exécutable. La taille de celui-ci peut être importante, mais il a l'avantage d'être autonome. Cette pratique a l'avantage de la simplicité, et elle ne requiert aucune action particulière de la part d'un éventuel système lors de l'exécution du programme. Elle est très utilisée en programmation embarquée (embedded).

XII-B. Bibliothèque dynamique

Une bibliothèque à édition de lien dynamique, est un fichier séparé (.dll, .so, ...) qui doit être livré avec l'exécutable. L'intérêt est que plusieurs applications peuvent se partager la même bibliothèque, ce qui est intéressant, surtout si sa taille est importante. Dans ce cas, les exécutables sont plus petits. Autre avantage, les défauts de la bibliothèque peuvent être corrigés indépendamment des applications. Ensuite, la correction est répercutée immédiatement sur toutes les applications concernées par simple mise à jour de la bibliothèque dynamique.

Une application qui utilise une bibliothèque dynamique doit réaliser le lien avec la bibliothèque à l'exécution. Pour cela, elle utilise des appels à des fonctions système spécifiques dans sa phase d'initialisation. Les détails dépendent de la plate-forme.

XIII. Pourquoi ma fonction ne modifie pas ma variable ?

Il y a deux façon de modifier la valeur d'un objet (aka variable) avec une fonction :

  • récupérer la valeur retournée par la fonction :
Sélectionnez
int x = f();

ou :

Sélectionnez
int x;
x = f();

avec :

Sélectionnez
int f(void);
  • passer l'adresse de la variable dont on veut modifier la valeur à la fonction :
Sélectionnez
int x;
f(&x);

avec :

Sélectionnez
void f(int *);

Si l'objet est un pointeur, la règle est la même :

  • retourner une valeur :
Sélectionnez
int *px = f();

avec :

Sélectionnez
int *f(void);
  • passer l'adresse :
Sélectionnez
int *px;
f(&px);

avec :

Sélectionnez
void f(int **);

Il n'y a aucune chance de modifier l'entier en faisant ceci :

Sélectionnez
int x;
f(x);

De même, il n'y a aucune chance de modifier le pointeur en faisant cela :

Sélectionnez
int *px;
f(px);

XIV. Les fonctions à nombre d'arguments variable

XIV-A. Introduction

Bien que leur usage ne soit pas recommandé, car il n'y a pas ou peu de contrôles possibles par le compilateur, le langage C supporte les fonctions à nombre variable d'arguments (variadic).

Attention ! Non seulement le nombre est variable, mais aussi le type.

Un exemple bien connu est printf() :

Sélectionnez
printf ("s:%d\n", __FILE__, __LINE__);

XIV-B. Interface

  • la fonction admet n'importe quel type de retour ;
  • elle a obligatoirement un paramètre fixe ;
  • les paramètres variables marqués par l'ellipse '...', sont obligatoirement les derniers.

Ces prototypes sont valides :

Sélectionnez
void f(int a, ...);
int f(int a, char *b, ...);
/* etc. */

Le rôle du dernier paramètre fixe est de fournir un moyen de déterminer le nombre et éventuellement le type des paramètres variables (donc passés après).

Exemple :

Sélectionnez
void f (int n, ...);

<...>
{
   f(3, 'a', 'b', 'c');
}

Ici, le dernier paramètre fixe indique le nombre de caractères passés.

Un effort particulier doit être fait au niveau de la documentation de la fonction, car il n'y a pas de contrôle possible de la part du compilateur. Si on se trompe de type ou de nombre, c'est le drame…

XIV-C. Implémentation

Elle varie évidemment selon la fonction à réaliser, mais elle s'appuie sur quelques principes communs :

  • inclure <stdarg.h> pour accéder aux fonctions et macros de manipulation des paramètres variables
  • définir une variable de traitement des variadics de type va_list
  • initialiser cette variable correctement avec va_start() et le nom du dernier paramètre fixe
  • récupérer et traiter les paramètres selon la spécification de la fonction
  • terminer l'analyse proprement avec va_end()
Sélectionnez
#include <stdarg.h>

void f (int n, ...)
{
   va_list va;
   va_start (va, n);

   /* traitement des paramètres selon la fonction */

   va_end (va);
}

La récupération des paramètre se fait séquentiellement de gauche à droite par appels successifs à va_arg() en précisant le type attendu, parmi 'int', 'long', 'double', 'void*' et 'char*' [C90].

Sélectionnez
int x = va_arg (va, int);
double y = va_arg (va, double);
char *z = va_arg (va, char *);
/* etc. */

Il doit y avoir correspondance parfaite entre le type de la variable et celui passé en paramètre à la macro va_arg().

Là encore, la vigilance est de rigueur, car aucun contrôle n'est possible de la part du compilateur.

Exemple d'implémentation de la fonction présentée au-dessus :

Sélectionnez
#include <stdarg.h>
#include <stdio.h>

void f (int n, ...)
{
   va_list va;
   va_start (va, n);

   int i;

   for (i = 0; i < n; i++)
   {
      int c = va_arg (va, int);
      putchar (c);
   }
   putchar ('\n');
   va_end (va);
}

int main (void)
{
   f (3, 'a', 'b', 'c');
   f (4, 'x', 'y', 'z', 't');

   return 0;
}
Sélectionnez
abc
xyzt

Process returned 0 (0x0)   execution time : 0.025 s
Press any key to continue.

XIV-D. Conclusion

Nous avons jeté les bases de la construction des fonctions variadics. Attention, ces fonctions sont une source importante de problèmes invisibles au yeux du compilateur.

Ne pas les utiliser dans des projets critiques sans une procédure de validation de l'implémentation et de l'usage extrêmement rigoureuse. Dans certains domaines sensibles (médical, avionique, nucléaire), leur usage est tout simplement interdit.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • ABC


Notes sur le langage C

Table des matières

  • XV. size_t, c'est quoi ?
  • XVI. Données
    • XVI-A. Initialisation
  • XVII. Structures
    • XVII-A. Structures visibles
    • XVII-B. Structures opaques
    • XVII-C. Pseudonymes (ou alias)
  • XVIII. Variables globales
    • XVIII-A. Nommage
    • XVIII-B. Organisation
    • XVIII-C. Définition
    • XVIII-D. Déclaration
    • XVIII-E. Utilisation
  • XIX. Champs de bits
  • XX. Bien utiliser const
    • XX-A. Objets simples
    • XX-B. Pointeurs
    • XX-C. Usage

XV. size_t, c'est quoi ?

size_t est le type retourné par l'opérateur sizeof. C'est un entier non signé. Il est suffisamment grand pour contenir la valeur représentant, en nombre de bytes (ou char), la taille du plus grand objet possible d'une implémentation donnée.

Il convient pour les tailles, les dimensions de tableau, les index croissants et non négatifs…

Ce type est défini dans <stddef.h> qui est inclus dans la plupart des headers standards courants (<stdio.h>, <stdlib.h> <string.h> etc.)

XVI. Données

Le langage C utilise 3 zones mémoire pour implémenter les données :

  • la mémoire statique, qui contient les variables permanentes (modifiables ou non) ;
  • la mémoire automatique qui contient les variables locales et les paramètres des fonctions ;
  • la mémoire allouée qui contient des variables dynamiques gérées à l'exécution par le programme (malloc() / free().

Les données en C sont caractérisées par :

  • leur portée ;
  • leur durée de vie.

La portée peut être :

  • locale à un bloc ;
  • limitée à une unité de compilation ;
  • illimitée ;
  • contrôlée par l'application.

La durée de vie peut être :

  • limitée à un bloc ;
  • permanente ;
  • contrôlée par l'application.

Exemples :

Sélectionnez
/* permanente de portée illimitée */
int a;

/* permanente de portée limitée à l'unité de compilation */
static int b;

<...>
{
/* locale (de portée limitée au bloc) */
   int c;

/* permanente de portée limitée au bloc */
   static int d;

/* contrôlée de portée limitée à la validité du pointeur (non NULL) */
   int *p = malloc (sizeof *p * 3);

   free (p), p = NULL;
}

XVI-A. Initialisation

Seules les données statiques sont initialisées avant le lancement de main(). Les autres ont une valeur indéterminée. Il est donc nécessaire de les initialiser avant utilisation (lecture). Sauf indication explicite contraire, l'initialisation par défaut des données statiques est 0.

XVII. Structures

XVII-A. Structures visibles

On appelle structure visible une structure dont les éléments sont visibles de l'utilisateur.

Une 'définition de structure' est le moyen par lequel le programmeur indique au compilateur comment est constitué une structure. Cette opération ne réserve aucune mémoire.

Sélectionnez
struct mastructure
   {
      type_1 element_a;
      type_2 element_b;
   };

Ensuite, une structure peut être instanciée, c'est à dire qu'une instance de cette structure est définie en mémoire.

Sélectionnez
struct mastructure mastructure;

il est autorisé d'utiliser le même nom, même si ce n'est probablement pas le meilleur des choix possible.

Ces informations suffisent à définir et instancier n'importe quelle structure 'visible'.

XVII-B. Structures opaques

On appelle structure opaque une structure dont les éléments ne sont pas visibles de l'utilisateur.

Pour cela, on utilise une définition réduite (ou incomplète) qui consiste à définir le nom de la structure sans en préciser le contenu.

Sélectionnez
struct mastructure;

Cette définition dite incomplète ne permet évidemment pas de créer un instanciation, puisque le compilateur ignore le contenu de la structure. Il n'a donc pas les moyens d'en déterminer la taille.

Par contre, il est possible de créer un pointeur de ce type :

Sélectionnez
struct mastructure *p;

Il devient alors possible de créer une fonction qui retourne un pointeur de ce type :

Sélectionnez
struct mastructure *fonction(void);

de passer ce pointeur en paramètre une fonction :

Sélectionnez
void fonction(struct mastructure *p);

d'en faire un élément de structure etc.

Évidemment, il faudra que la structure soit définie 'quelque part' afin qu'elle soit instanciable et que ses éléments soient manipulables.

On va donc créer un fichier source (.c) séparé d'implémentation contenant les fonctions permettant la création (instanciation) des données, et une interface (header ou .h) ne comportant que la définition incomplète de la fonction et, au minimum, les 2 fonctions permettant la création et la suppression d'une instance de la structure.

Soit la structure 'xxx'. On obtient :

Sélectionnez
/* xxx.h Interface */
#ifndef H_XXX
#define H_XXX

/* définition incomplète de structure */
struct xxx;

/* prototypes des fonctions */
struct xxx *xxx_create (void);
void xxx_delete (struct xxx *p);
/* etc. */

#endif
Sélectionnez
/* xxx.c Implémentation */
#include "xxx.h"

/* définition de la structure (exemple) */
struct xxx
{
   int a;
   char b[10];
};

/* fonctions publiques */
struct xxx *xxx_create (void)
{
   /* a completer */
}

void xxx_delete (struct xxx *p)
{
   /* a completer */
}

Exemple d'utilisation :

Sélectionnez
/* test.c */
#include "xxx.h"

#include <stddef.h>

int main (void)
{
   /* instanciation */
   struct xxx *p = xxx_create ();

   /* tout appel de fonction peut échouer... */
   if (p != NULL)
   {
      /* utilisation de l'objet xxx
         (via de nouvelles fonctions à créer)

         ...
       */

      /* fin d'utilisation : destruction de l'objet */
      xxx_delete (p), p = NULL;
   }
   return 0;
}

Je laisse au lecteur le soin de proposer une ou des implémentations de xxx_create() et de xxx_delete(), sachant qu'on a pas forcément besoin d'un nombre illimité d'instanciations…

XVII-C. Pseudonymes (ou alias)

Il est possible, afin de simplifier l'écriture (notamment pour les interfaces publiques), de remplacer le nom de la structure par un nom différent (alias ou pseudonyme) généralement plus court. Il est recommandé de ne pas abuser de l'abstraction, car les possibilités sont réduite en C et il est bon que le programmeur garde en tête qu'il manipule des pointeurs.

Ceci est possible :

Sélectionnez
typedef struct xxx xxx_s;

mais ceci est déconseillé :

Sélectionnez
typedef struct xxx *xxx; /* /!\ */

Détails d'application dans l'article Les types abstraits de données (ADT).

XVIII. Variables globales

Une variable globale est une variable définie en dehors d'une fonction et de portée globale. Sa durée de vie est égale à celle du programme. Elle est initialisée par défaut (0) ou explicitement avant l'exécution de main(). Sa valeur est persistante.

Un usage abusif des variables globales est fortement déconseillé pour diverses raisons :

  • on ne sait ni comment ni quand ni qui accède à cette variable. Cela rend le code intestable et incompréhensible. On n'a aucune certitude sur le code ;
  • cela crée une dépendance, ce qui le code rend non modulaire et non réutilisable ;
  • l'instance étant unique, ça rend le code impropre à la récursion et à l'utilisation dans des threads et même au simple appel imbriqué.

Ceci dit, il est des cas rares (ou plus fréquents s'il s'agit de lecture seule) où les variables globales sont utiles, voire indispensables. Dans le cadre d'une application professionnelle, ces cas doivent être justifiés. Voici comment les définir correctement dans le cadre d'une application composée d'unités de compilations séparées.

XVIII-A. Nommage

Il est recommandé d'utiliser le préfixe g_ ou G_ pour signifier qu'une variable est globale.

XVIII-B. Organisation

Il est préférable pour éviter la dispersion, d'utiliser une ou des structures de variables globales regroupées par fonction, plutôt qu'une multitude de variables.

XVIII-C. Définition

Il est recommandé que la définition d'une variable globale soit faite exclusivement dans un fichier source (.c). Ce fichier doit inclure le fichier de déclaration (en-tête).

Sélectionnez
/* data.c */

#include "data.h"

int G_x;

/* la taille du tableau est définie
 * dans la déclaration.
 */
double G_a[];

data_s G_data;

XVIII-D. Déclaration

Il est recommandé que la déclaration d'une variable globale soit faite exclusivement dans un fichier d'entête (.h). Ce fichier doit être inclus dans le fichier de définition et dans tous les fichiers d'utilisation (.c). Comme tous les fichiers d'en-têtes, celui-ci dispose de protections contre les inclusions multiples.

Sélectionnez
#ifndef H_DATA
#define H_DATA

/* data.h */

typedef struct
{
   int a;
   char b[123];
}
data_s;

extern int G_x;

/* la dentition de la taille du tableau est unique. Elle est faite ici. */
extern double G_a[12];

extern data_s G_data;

#endif /* guard */

XVIII-E. Utilisation

Il est recommandé que le fichier qui utilise une variable globale inclue le fichier de déclaration (.h).

Sélectionnez
/* appli.c */

#include "data.h"

int main (void)
{
   G_x = 123;

   G_data.a = 456;

   G_a[3] = 123.456;

   return 0;
}

XIX. Champs de bits

Afin de réduire la taille des objets, il est possible de définir un champ de bits. La définition doit se faire dans une structure. Le type de l'objet unitaire doit être int ou unsigned int (recommandé) ou _Bool (bool) en C99.

Sélectionnez
typedef struct
{
   unsigned a:1; /* variable de largeur 1 bit */
   unsigned b:3; /* variable de largeur 3 bits */

   /* etc. */
}
data_s;

Il faut garder à l'esprit que l'implantation mémoire des bits n'est pas spécifiée par le langage C. (Et j'ai effectivement constaté sur le terrain des différences selon les implémentations, notamment concernant l'ordre des bits).

Autant une utilisation interne est possible et peut se justifier pour réduire la taille des objets (stockage en mémoire, notamment), extrait de CLIB Module DATE (date.h) (Les tailles indiquées en commentaire sont les tailles minimales garanties)…

Sélectionnez
typedef unsigned int uint;
<...>
typedef struct
{
   /* 16 bits */
   int year;        /* -32767..+32767 */
   uint month:4;    /* 0-15 */
   uint day:5;      /* 0-31 */

   uint hour:5;     /* 0-31 */
   uint minute:6;   /* 0-63 */
   uint second:6;   /* 0-63 */
}
sDATE;

… autant il est illusoire d'utiliser les champs de bits pour créer une interface avec l'extérieur du programme, comme un flux de bytes ou un périphérique en accès direct (mémoire, bus I/O etc.).

Autre pratique non portable, faire une union entre un champ de bits et une variable en s'imaginant pouvoir accéder à la variable, soit d'un bloc, soit bit à bit.

La solution portable pour accéder aux bits d'une variable est d'utiliser les opérateurs binaires (&, |, ~, <<, >>, ^)

XX. Bien utiliser const

Voici à quoi sert le qualificateur const et comment l'utiliser correctement.

XX-A. Objets simples

Le mot clé 'const' est un qualificateur (qualifier) d'objet. Il lui fait perdre sa qualité par défaut qui est 'accessible en lecture ou en écriture' pour le modifier en 'accessible en lecture seule'. Par exemple :

Sélectionnez
int x = 3;
int const y = 4;

x = 5; /* accès en écriture possible */
y = 6; /* accès en écriture interdit */

Le compilateur signale l'erreur.

On peut placer indifféremment le qualificateur const avant ou après le type.

Sélectionnez
int const a = 7;
const int b = 8;

Mais je conseille néanmoins la première forme, car elle est beaucoup plus claire (notamment avec les pointeurs).

Il est techniquement possible de définir un objet const non initialisé :

Sélectionnez
int const c;

Évidemment, l'intérêt est limité, mais il a son application dans un contexte particulier : les paramètres de fonctions.

XX-B. Pointeurs

Le cas des pointeurs est un peu plus complexe, puis qu'il y a en quelque sorte deux objets pour le prix d'un !

  • le pointeur lui même qui peut être qualifié const ;
  • l'objet pointé qui peut lui même être qualifié const.

Pour le pointeur, celui-ci étant un objet comme autre, la même règle s'applique, sachant que const doit être placé juste avant l'identificateur, c'est à dire après le dernier * :

Sélectionnez
int a;
int * const pa = &a;

pa++; /* interdit */
*pa = 123; /* OK */

Mais un autre qualificateur const peut être utilisé pour préciser les droits du pointeur sur l'objet pointé.

Celui ci se place à la gauche de l'*, avant ou après le typem :

Sélectionnez
int a = 123;
int const * pa = &a;
const int * pb = &a;

mais là encore, pour des question de clarté du code, je recommande la première forme. Ce qualificateur interdit la modification de l'objet via le pointeur (mais s'il n'est pas lui même qualifié const, l'objet reste modifiable directement, évidemment).

Sélectionnez
int a = 123;
int const * pa = &a;

*pa = 456; /* interdit */
a = 456; /* OK */

Par contre, attention. Il est techniquement possible de définir un pointeur sur un objet qualifié const et de tenter de modifier l'objet. Cela produit un comportement indéfini qui n'est pas forcément signalé par le compilateur.

Sélectionnez
int const a = 123;
int * pa = &a;

*pa = 456; /* comportement indéfini */

Cependant, le plus souvent, le compilateur signale un problème au moment de l'affectation du pointeur.

Sélectionnez
int * pa = &a;

mais il convient de rester extrêmement prudent. Le C est un langage qui demande rigueur et maîtrise.

Bien évidemment, le typecast n'est pas la solution :

Sélectionnez
int const a = 123;
int * pa = (int *) &a; /* NE PAS FAIRE CECI */

il ne fait éventuellement que masquer le problème au compilateur ("je sais ce que fais"), mais il ne résout rien et le comportement indéfini est toujours là.

XX-C. Usage

Le rôle du qualificateur const est particulièrement utile avec les pointeurs, notamment sur des chaînes de caractères, qui, rappelons le, ne sont pas modifiables. Il est fortement recommandé de définir tout pointeur sur une chaîne de caractères avec le qualificateur const :

Sélectionnez
char const *p = "hello";

Il est utile aussi pour les pointeurs passés en paramètre à des fonctions. Il permet en effet de restreindre l'accès à la variable pointée à un mode 'lecture seule', ce qui évite bien des erreurs de codage. (La modification d'une variable étant une opération lourde de conséquences si elle est faite au mauvais moment).

Une fonction qui affiche le contenu d'un tableau ou d'une structure, par exemple, n'a pas à la modifier. On fixe donc les règles du jeu dès la définition du prototype :

Sélectionnez
void display (T const *p)

Une utilisation astucieuse et intelligente du qualificateur const permet d'écrire du code plus sûr.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • XXI. Les pointeurs démythifiés !
    • XXI-A. Introduction
    • XXI-B. Définition
      • XXI-B-1. Pointeur sur objet
      • XXI-B-2. Pointeur de fonction
        • XXI-B-2-a. Remarques importantes
    • XXI-C. Du bon usage des pointeurs
  • XXII. Les pointeurs, ça sert à quoi ?
    • XXII-A. A quoi ça sert?
      • XXII-A-1. Petit rappel.
      • XXII-A-2. Comment faire ?
  • XXIII. Un tableau n'est pas un pointeur ! Vrai ou faux ?
  • XXIV. Passer un tableau à une fonction
    • XXIV-A. Introduction
    • XXIV-B. Tableau à une dimension
    • XXIV-C. Tableau à n dimensions
  • XXV. Retourner un tableau
  • XXVI. char* char**
    • XXVI-A. Introduction
    • XXVI-B. Chaine de caractères
    • XXVI-C. Le type char *
    • XXVI-D. Le type char **
  • XXVII. Initialisation des tableaux de caractères
  • XXVIII. Qu'est-ce qu'une chaine litterale ?
  • XXIX. Bien utiliser malloc()
    • XXIX-A. Suppression du cast
      • XXIX-A-1. Compilateur non ISO
      • XXIX-A-2. Compilateur C++
    • XXIX-B. Déterminer la taille sans le type
  • XXX. Bien utiliser realloc()
  • XXXI. Comment créer un tableau dynamique à deux dimensions ?

XXI. Les pointeurs démythifiés !

XXI-A. Introduction

Le langage C est indissociable de la notion de pointeur. Ce mot mythique en effraye plus d'un, et il est bon de démythifier enfin les pointeurs, sources de tant d'erreurs de codage, de discussions sans fin et de contre vérités...

XXI-B. Définition

Un pointeur est une variable dont la valeur est une adresse.

On distingue deux grandes familles de pointeurs :

  • les pointeurs sur objet ;
  • les pointeurs de fonction.

XXI-B-1. Pointeur sur objet

En C, un objet est essentiellement une variable mémoire (par opposition à registre ou constante), modifiable ou non, mais munie d'une adresse.

Un pointeur sur objet (aussi appelé communément 'pointeur') est une variable qui peut contenir l'adresse d'un objet.

Pour définir un pointeur, on utilise un type, puis le signe '*' et enfin l'identificateur, suivit de ';' si on ne désire pas l'initialiser à la déclaration (peu recommandé). Sinon, on utilise l'opérateur '=', suivit de la valeur d'initialisation. Si le pointeur est déclaré dans un bloc, on peut utiliser la valeur retournée par une fonction.

Sélectionnez
/* Definition d'un pointeur sur int */
   int *p_int;

Pour initialiser un pointeur, on peut soit :

  • lui donner la valeur 0 ou NULL, qui signifie "invalide, ne pas utiliser" ;
  • lui donner l'adresse d'une variable ;
  • lui donner la valeur retournée par les fonctions malloc(), realloc(), calloc() ;
  • s'il est de type void ou FILE, lui donner la valeur retournée par fopen() ;
  • s'il est de même type, ou void, lui donner la valeur d'un autre pointeur réputé correctement initialisé.

Pour accéder à la valeur pointée, le pointeur doit être typé (donc différent de void). Dans ce cas, on peut obtenir la valeur en utilisant l'opérateur de déréférencement '*'.

Sélectionnez
   /* Définition de la variable 'a' valant 4 */
   int a = 4;

   /* Définition d'un pointeur 'p' initialisé avec l'adresse de la variable 'a' */
   int *p = &a;

   /* Définition de la variable 'b' non initialisée */
   int b;

   /* Récupération de la valeur de 'a' dans 'b' via le pointeur 'p' */
   b = *p;

   /* 'b' vaut maintenant 4 */

XXI-B-2. Pointeur de fonction

Une fonction a une adresse qui est le nom de cette fonction. Un pointeur de fonction peut recevoir cette adresse. Il est possible, via un pointeur de fonction correctement initialisé, d'appeler une fonction.

Cette capacité du langage C lui confère une puissance rarement égalée, qui permet d'écrire du code flexible, dont les adresses des fonctions peuvent être définies à l'exécution. Cela permet de 'personnaliser' des fonctions génériques selon les besoins.

La définition des pointeurs de fonctions est un peu complexe, et a tendance à alourdir le code :

Sélectionnez
   /* pointeur sur une fonction avec deux paramètres */
   int (*pf) (int, char **);

   /* prototype d'un fonction ayant un pointeur de fonction comme paramètre */
   int fun (int (*pf) (int, char **));

Si on doit manipuler des fonctions qui retournent un pointeur de fonction, ou des tableaux de pointeurs de fonction, le code devient rapidement illisible. C'est pourquoi il est fortement conseillé, et ce dans tous les cas, d'utiliser un typedef pour créer un alias sur le type de la fonction.

Sélectionnez
   /* définition d'un alias */
   typedef int fun_f (int, char **);

   /* définition d'un pointeur de fonction de ce type */
   fun_f *pf;

   /* prototype d'une fonction ayant un pointeur de fonction comme paramètre */
   int fun (fun_f *pf);

   /* tableau de pointeurs de fonction */
   fun_f *pf[10];

   /* prototype d'une fonction retournant un pointeur de fonction */
   fun_f *getfunc (int);

La lecture et la maintenance du code s'en trouvent considérablement allégées.

Pour initialiser un pointeur de fonction, on peut soit :

  • lui donner la valeur 0 ou NULL, qui signifie "invalide, ne pas utiliser" ;
  • lui donner l'adresse d'une fonction ;
  • s'il est de même type, lui donner la valeur d'un autre pointeur réputé correctement initialisé.

Pour utiliser le pointeur, il suffit de l'invoquer comme une fonction.

Sélectionnez
   /* définition d'un alias */
   typedef int fun_f (int);

   /* définition d'un pointeur de fonction de ce type */
   fun_f *pf;

   /* prototype d'une fonction du même type */
   int function (int);

   /* NOTA : on peut aussi utiliser le type */
   fun_f function;

   /* initialisation du pointeur de fonction */
   pf = fonction;

   /* appel de la fonction via le pointeur de fonction */
   pf (123);
XXI-B-2-a. Remarques importantes
  • void* n'est pas un type correct pour un pointeur de fonction ;
  • Il n'existe pas de type générique pour les pointeurs de fonctions.

XXI-C. Du bon usage des pointeurs

Un pointeur est avant tout une variable. Comme toutes les variables, elle doit être initialisée avant d'être utilisée.

Pour un pointeur en général, l'utilisation signifie le passage de sa valeur à une fonction. Pour un pointeur sur objet, c'est le déréférencement par l'opérateur '*'. Pour un pointeur de fonction, c'est l'appel de cette fonction via le pointeur.

Il est recommandé de donner une valeur significative à un pointeur. Soit il est invalide, et on lui donne la valeur 0 ou NULL, soit il est valide, et dans ce cas sa valeur est celle de l'adresse d'un objet ou d'une fonction valide. Si le bloc mémoire ou la fonction deviennent invalides, il est recommandé de donner au pointeur la valeur 0 ou NULL.

Sélectionnez
   /* Définition du pointeur. Il est initialisé à l'état invalide */
   int *p = NULL;

   /* le pointeur est initialisé avec l'adresse d'un tableau
    * dynamique de 4 éléments
    */
   p = malloc (4 * sizeof *p);

   /* en cas d'échec d'allocation, malloc() retourne NULL */
   if (p != NULL)
   {
      /* ... */

      /* après utilisation, l'espace mémoire est libéré */
      free (p);

      /* le pointeur est forcé à l'état invalide */
      p = NULL;
   }

XXII. Les pointeurs, ça sert à quoi ?

Les pointeurs sont un peu l'essence même du C et de tous les autres langages. Sauf que dans beaucoup de langages, cette notion est considérée comme honteuse (ou trop technique), et des astuces sont utilisées pour 'cacher' les pointeurs.

En C, on n'a pas honte des pointeurs et on les affiche ostensiblement.

XXII-A. A quoi ça sert?

XXII-A-1. Petit rappel.

Un paramètre de fonction est une variable locale de la fonction dont la valeur est donnée par l'appelant. Si on modifie cette valeur, on ne fait que modifier une variable locale. Exemple :

Sélectionnez
{
   int x = 123;
   f (x);
}

avec :

Sélectionnez
void f (int a)
{
   a++;
}

Déroulement des opérations :

  • x prend la valeur 123 ;
  • appel de la fonction f() avec en paramètre la valeur de x (soit 123) ;
  • dans f(), la variable locale a prend la valeur 123 ;
  • la valeur de a est augmentée de 1 et devient 124 ;
  • la fonction se termine ;
  • la valeur de x n'a pas changé, elle vaut toujours 123.

Si on avait voulu modifier x en appelant f(), c'est raté.

XXII-A-2. Comment faire ?

Tout simplement en donnant un moyen à la fonction qui lui permettre de modifier la variable x. Ce moyen est simple. Il suffit de lui donner l'adresse de x et elle pourra alors modifier x grâce à un pointeur et à l'opérateur de déréférencement (*).

En fait, on va faire ceci :

Sélectionnez

{
   int x = 123;
   int *p = &x;
   (*p)++;
}

C'est à dire

  • x prend la valeur 123 ;
  • p prend l'adresse de x ;
  • x est incrémenté via p et l'opérateur de déréférencement. Il vaut maintenant 124.

sauf que les opérations vont être réparties entre l'appelant et l'appelé comme ceci :

Sélectionnez
{
   int x = 123;
   f (&x);
}

avec

Sélectionnez
void f (int *p)
{
   (*p)++;
}
  • x prend la valeur 123 ;
  • l'adresse de x est passée à la fonction ;
  • dans f(), la variable locale p est initialisée avec l'adresse de x ;
  • la valeur de x est incrémentée de 1 via p et l'opérateur de déréférencement. Elle vaut maintenant 124 ;
  • la fonction se termine ;
  • la valeur de x a changé, elle vaut maintenant 124.

Voilà donc un exemple d'utilisation des pointeurs. Pour un tableau, par exemple, il n'y a pas le choix. La seule façon de faire est de passer l'adresse du premier élément du tableau via un pointeur sur le même type que l'élément.

Ensuite, on a accès à tous les éléments du tableau. Non seulement le [0] en *p , mais aussi aux autres, grâce aux propriétés de l'arithmétique des pointeurs.

En effet, le type étant connu, l'adresse des autres éléments est tout simplement p+1, p+2 etc.

L'élément lui même se trouve donc en *(p + 1), *(p + 2) etc. Le langage C définit cette écriture comme strictement équivalente à p[1], p[2] etc. On dit alors que la 'notation tableau' peut s'appliquer aux pointeurs. Mais cela ne signifie pas qu'un pointeur soit un tableau ni inversement comme on le lit parfois.

Ce principe est massivement utilisé avec les chaînes de caractères, qui, rappelons le, sont des tableaux de char initialisés avec des valeurs de caractères et terminés par un 0.

XXIII. Un tableau n'est pas un pointeur ! Vrai ou faux ?

Le tableau est probablement le concept le plus difficile à définir correctement en C. Il y a en effet beaucoup de confusion sur les termes : tableau, adresse, pointeur, indices... Ce petit article essaye de tirer les choses au clair.

  • un tableau est une séquence d'éléments de types identiques ;
  • le nom du tableau est invariant. Il a la valeur et le type de l'adresse du premier élément du tableau. C'est donc une adresse typée (encore appelée pointeur constant[1]). Étant de la même nature qu'un pointeur, les même règles d'adressage s'appliquent, à savoir que le premier élément est en tab, soit tab + 0 et que son contenu est donc *(tab + 0). De même le contenu du deuxième élément est *(tab + 1) etc. ;
  • cette syntaxe étant un peu lourde, le langage C définit une simplification qui est tab[0], tab[1] etc. Le nombre entre les crochets est appelé indice. Son domaine de définition pour un tableau de taille N est 0 à N-1.

représentation graphique d'un tableau de 4 éléments :

Sélectionnez
tab    : |---------------|
tab[0] : |---|
tab[1] :     |---|
tab[2] :         |---|
tab[3] :             |---|

[1] C'est là que ce situe la difficulté. Le langage C parle de non-modifiable L-value ce qui signifie que c'est un objet (il a une adresse), non modifiable. On ne peut pas changer sa valeur. On ne peut changer que la valeur de ses éléments.

XXIV. Passer un tableau à une fonction

XXIV-A. Introduction

Rappel : en langage C, les passages de paramètres se font exclusivement par valeur.

Le langage C n'autorise pas le passage d'un tableau en paramètre à une fonction. La raison est probablement une recherche d'efficacité, afin d'éviter des copies inutiles.

Le but de 'passer un tableau' à une fonction est en fait de permettre à celle-ci d'accéder aux éléments du tableau en lecture ou en écriture. Pour se faire, l'adresse du début du tableau et le type des éléments suffisent à mettre en œuvre l'arithmétique des pointeurs. Un paramètre 'pointeur' est donc exactement ce qu'il faut.

XXIV-B. Tableau à une dimension

Soit l'appelant :

Sélectionnez
int main (void)
{
   int tab[5];

   clear (tab);

   return 0;
}

Le prototype de la fonction appelée doit comporter un pointeur du même type que les éléments du tableau, pour recevoir l'adresse du premier élément de celui-ci, soit :

Sélectionnez
void clear (int *p);

La fonction va donc utiliser le paramètre pointeur, dont la valeur est l'adresse du premier élément du tableau, pour accéder aux éléments du tableau. Par exemple, les mettre à 0 :

Sélectionnez
void clear (int *p)
{
   *(p + 0) = 0; /* premier élément, */
   *(p + 1) = 0; /* deuxième élément, */
   *(p + 2) = 0; /* troisième élément, */
   *(p + 3) = 0; /* quatrième élément, */
   *(p + 4) = 0; /* dernier élément (cinquième) */
}

Afin d'alléger l'écriture, le langage C autorise l'utilisation de la syntaxe des tableaux pour accéder aux éléments :

Sélectionnez
void clear (int *p)
{
   p[0] = 0; /* premier élément, */
   p[1] = 0; /* deuxième élément, */
   p[2] = 0; /* troisième élément, */
   p[3] = 0; /* quatrième élément, */
   p[4] = 0; /* dernier élément (cinquième) */
}

Cette implémentation est évidemment théorique, car dans la pratique, on utilisera une boucle et un paramètre supplémentaire (nombre d'éléments) afin d'écrire un code plus souple et auto adaptatif.

Sélectionnez
void clear (int *p, size_t nb)
{
   size_t i;

   for (i = 0; i < nb; i++)
   {
      p[i] = 0;
   }
}

int main (void)
{
   int tab[5];

   clear (tab, 5);

   return 0;
}

XXIV-C. Tableau à n dimensions

Rappelons que lorsqu'on définit un paramètre, les syntaxes type *param et type param[] sont sémantiquement équivalentes.

Pour définir un paramètre de type pointeur sur un tableau à deux dimensions, on serait tenté d'écrire type p[][], ce qui serait une erreur de syntaxe. En effet, la notation [] est une notation abrégée de [TAILLE] dans les cas où cette taille est ignorée par le compilateur, c'est à dire lorsque la dimension concernée est la plus à gauche. Les syntaxes suivantes sont légales :

Sélectionnez
type_retour fonction (int p[])
type_retour fonction (int p[12])
type_retour fonction (int p[][34])
type_retour fonction (int p[56][78])
etc.

Pour déterminer le nombre d'éléments d'un tableau, on peut utiliser les propriétés des tableaux. En effet, un tableau est une suite contiguë d'éléments identiques. Le nombre d'éléments est donc tout simplement le rapport entre la taille en bytes du tableau (sizeof tab) et la taille d'un élément en bytes (sizeof tab[0] ou sizeof *tab), que l'on généralise sous la forme d'une macro bien connue :

Sélectionnez
#define NB_ELEM(a) (sizeof (a) / sizeof *(a))

XXV. Retourner un tableau

En C une fonction ne sait pas 'retourner un tableau'.

Ce qu'elle sait faire, c'est retourner une valeur. La pratique courante est de retourner l'adresse du premier élément du tableau. Pour cela, on définit le type retourné comme un pointeur sur le type d'un élément du tableau.

Sélectionnez
T *f();

T représente le type d'un élément du tableau

Évidemment, cette adresse doit être valide après exécution de la fonction. Même si c'est techniquement possible, il est donc hors de question de retourner l'adresse d'un élément appartenant à un tableau local.

  • soit on passe à la fonction l'adresse du premier élément d'un tableau existant, et elle peut retourner l'adresse de ce tableau ;
  • soit la fonction fait une allocation dynamique et retourne l'adresse du bloc alloué ;
  • on peut aussi retourner l'adresse du premier élément d'une chaîne littérale. Celle-ci est statique (attention accès en lecture seule, qualificateur 'const' recommandé) ;
  • enfin, il est techniquement possible de retourner l'adresse du premier élément d'un tableau statique, mais cette pratique est déconseillée, car elle rend la fonction non ré-entrante, donc impropre à plusieurs utilisations comme les appels imbriqués ou les threads... Plusieurs fonctions du C sont malheureusement victimes de ce défaut (ctime() asctime(), strtok() etc.)

XXVI. char* char**

XXVI-A. Introduction

En langage C, beaucoup de problèmes de codage et d'exécution proviennent d'une confusion entre tableaux et pointeurs. C'est particulièrement vrai avec les chaînes de caractères, au point d'utiliser le terme 'Char étoile' à la place du terme 'Chaîne de caractères' ou 'Char étoile étoile' à la place de 'Tableau de chaînes'. Qu'en est-il exactement ?

XXVI-B. Chaine de caractères

Une chaîne de caractères est un tableau de char terminé par un 0. Une chaîne littérale n'est pas modifiable.

Sélectionnez
     /* interdit */
   "hello"[2] = 'x';

   /* autorise' */
   char s[] = "hello";

   s[2] = 'x';

XXVI-C. Le type char *

Un pointeur sur char est une variable qui peut contenir NULL, l'adresse d'un char ou celle d'un élément d'un tableau de char. Si c'est un tableau, on n'a aucune information sur le nombre d'éléments du tableau pointé. Néanmoins, si c'est une chaîne valide, elle est terminée par un 0 qui sert de balise de fin.

Sélectionnez
char c;
   char *pa = &c;

   char *pb = 0;
   char *pc = NULL;

   char s[] = "hello";

   char *pd = s;
   char *pe = s + 3;

   /* une chaîne littérale n'étant pas modifiable,
    * il est conseillé de qualifier l'objet avec
    * 'const' (read-only)
    */
   char const *pf = "hello";
   char const *pg = "hello" + 2;

Un petit schéma pour modéliser :

Représentation graphique d'un objet 'c' de type char non initialisé :

Sélectionnez
char c;

   :---------:--------:
   : adresse : valeur :
   :         :        :
   :---------:--------:
   : &c      : ???    :
   :---------:--------:

Représentation graphique d'un objet 'c' de type char après initialisation :

Sélectionnez
c = 'A';

   :---------:--------:
   : adresse : valeur :
   :         :        :
   :---------:--------:
   : &c      : 'A'    :
   :---------:--------:

Représentation graphique d'un objet 'p' de type char * non initialisé :

Sélectionnez
char *p;

   :---------:--------:---------:
   : adresse : valeur : valeur  :
   :         :        : pointée :
   :---------:--------:---------:
   : &p      : ???    : ???     :
   :---------:--------:---------:

Représentation graphique d'un objet 'p' de type char * après initialisation (NULL) :

Sélectionnez
p = NULL;

   :---------:--------:---------:
   : adresse : valeur : valeur  :
   :         :        : pointée :
   :---------:--------:---------:
   : &p      : NULL   : ???     : --> NULL
   :---------:--------:---------:

Représentation graphique d'un objet 'p' de type char * après initialisation (adresse d'une variable) :

Sélectionnez
p = &c;

   :---------:--------:---------:     :---------:--------:
   : adresse : valeur : valeur  :     : adresse : valeur :
   :         :        : pointée :     :         :        :
   :---------:--------:---------:     :---------:--------:
   : &p      : &c     : 'A'     : --> : &c      : 'A'    :
   :---------:--------:---------:     :---------:--------:

Représentation graphique d'un objet 'p' de type char * après initialisation (adresse d'une chaîne modifiable) :

Sélectionnez
char s[]="ab";
   p = s;

   :---------:---------:---------:     :---------:--------:
   : adresse : valeur  : valeur  :     : adresse : valeur :
   :         :         : pointée :     :         :        :
   :---------:---------:---------:     :---------:--------:
   : &p      : &s[0]   : 'a'     : --> : s+0     : 'a'    :
   :         : ou s+0  :         :     :---------:--------:
   :         : ou s    :         :     : s+1     : 'b'    :
   :---------:---------:---------:     :---------:--------:
                                       : s+2     : 0      :
                                       :---------:--------:

Le pointeur sur char est principalement utilisé pour les paramètres 'chaînes de caractères' des fonctions et pour des manipulations de chaînes de caractères.

Mais cela n'autorise pas à utiliser le terme 'char étoile' à la place de 'chaîne de caractères'.

XXVI-D. Le type char **

Un pointeur sur pointeur de char est une variable qui peut contenir NULL, l'adresse d'un pointeur de char ou celle d'un élément d'un tableau de pointeurs de char. Si c'est un tableau, on a aucune information sur le nombre d'éléments du tableau pointé. On peut parfois ajouter un élément de valeur NULL pour délimiter le tableau de pointeurs. Un petit schéma pour modéliser :

XXVII. Initialisation des tableaux de caractères

Il est possible d'initialiser un tableau de char au moment de sa définition avec une constante qui ressemble a une chaîne de caractères. Il faut cependant faire attention, car il n'est pas garanti que le tableau ainsi initialisé forme une chaîne C valide (c'est à dire terminée par un 0).

En effet, la liste de caractères placée entre double quotes et servant à initialiser le tableau de char n'est en aucun cas une chaîne de caractères. C'est une simple liste de caractères. Les zéros que l'on voit dans le tableau sont le résultat du comportement standard du C qui complète à 0 les tableaux partiellement initialisés.

Exemples :

Sélectionnez
char s[4] = "ab";

le tableau est initialisé avec {'a', 'b', 0, 0} : Chaîne valide

Sélectionnez
char s[4] = "abc";

le tableau est initialisé avec {'a', 'b', 'c', 0} : Chaîne valide

Sélectionnez

char s[4] = "abcd";

le tableau est initialisé avec {'a', 'b', 'c', 'd'} : Chaîne invalide /!\

Sélectionnez
char s[4] = "abcde";

Ne compile pas (trop d'initialisateurs)

XXVIII. Qu'est-ce qu'une chaine litterale ?

Une chaîne littérale, telle qu'elle apparaît dans un source C, est une séquence de caractères entourée de guillemets (double quotes)

Sélectionnez
"hello"

Elle ne doit pas être confondue avec la liste de caractères servant à initialiser un tableau de char, par exemple :

Sélectionnez
char s[] = "hello";

(les détails sont indiqués ici).

Une chaîne littérale désigne en réalité l'adresse du premier élément d'un tableau de char anonyme non modifiable, situé en mémoire statique, initialisé avec la séquence de caractères mentionnés et terminé par un 0.

Tout se passe comme si on avait ceci :

Sélectionnez
static char const identificateur_connu_seulement_du_compilateur[] = {'h','e','l','l','o',0};

Si la chaîne apparaît dans un paramètre de fonction :

Sélectionnez
f ("hello");

c'est cette valeur (l'adresse) qui est passée à la fonction dans son paramètre :

Sélectionnez
void f (char const *s);

Si la chaîne sert à initialiser un pointeur :

Sélectionnez
char const *p = "hello";

p = "bye";

c'est cette valeur (l'adresse) qui est stockée dans le pointeur.

Rappel : le mot clé const sert à qualifier l'objet de non modifiable

XXIX. Bien utiliser malloc()

Il est fréquent de rencontrer ce genre de code :

Sélectionnez
#include <stdlib.h>
...
{
   /* création d'un tableau de 10 int */
   size_t n = 10;
   int *p = (int*) malloc (sizeof (int) * n);

   if (p != NULL)
   {
      ...
}

Ce code est correct et ne présente pas de comportement indéterminé. Cependant, il est inutilement compliqué et peut être amélioré de plusieurs façons.

XXIX-A. Suppression du cast

Il est d'usage d'éviter les casts en C. Certains sont indispensables, d'autres non. Ici, par exemple, et contrairement aux idées reçues, le cast est inutile, et on peut parfaitement écrire :

Sélectionnez
{
   int *p = malloc (sizeof (int) * n);
}

Il est cependant des cas rares où le cast est indispensable :

  • le compilateur n'est pas conforme à ISO C-90 ou ISO C-99 ;
  • le compilateur n'est pas C mais par exemple pré-C++98.

XXIX-A-1. Compilateur non ISO

Il est rare de nos jours d'utiliser un compilateur datant d'avant la normalisation du langage C (1989 aux USA, 1990 au niveau international). En effet, ces compilateurs ne supportent pas les prototypes, ce qui les rend impropre à produire du code cohérent, à moins d'utiliser un outil de vérification indépendant comme PCLint.

Le cas peut cependant se produire, s'il s'agit de maintenir du code ancien avec une chaîne de compilation ancienne. Dans ce cas, effectivement, le cast est indispensable si le type du pointeur et différent de char*.

L'opportunité de conserver une telle pratique est donc laissée à l'appréciation du programmeur. Il semble cependant assez évident que dans les nouveaux développements utilisant un compilateur ISO, il est inutile d'ajouter le cast.

XXIX-A-2. Compilateur C++

Il est techniquement possible, à de rares exceptions syntaxiques près, de faire compiler du code C par un compilateur C++. Néanmoins, cette pratique est rarement justifiée et est largement déconseillée.

En effet, en dehors des points syntaxiques évidents (comme par exemple la conversion de type explicite void* <-> type* qui justement oblige à utiliser le cast) plusieurs points de sémantique diffèrent entre les deux langages. En l'état actuel des normes, les spécifications C++98 et C99 ont même plutôt tendance à diverger (cette situation pourrait changer en 2005 avec une nouvelle révision de C++ intégrant les nouveautés de C99).

On peut aussi se demander pourquoi on utiliserait malloc() et free() en C++, alors que ce langage dispose des opérateurs new et delete . D'autre part, en C++98, un cast se fait avec static_cast<...>.

Lire à ce sujet : Incompatibilities Between ISO C and ISO C++

XXIX-B. Déterminer la taille sans le type

Il est courant de déterminer la taille d'un objet en utilisant son type

Sélectionnez
{
   int *p = malloc (sizeof (int));
}

Si le type change, on est obligé de modifier deux fois le code :

Sélectionnez
{
   long *p = malloc (sizeof (long));
}

Lorsqu'il s'agit d'un pointeur typé, il existe une technique alternative qui consiste à utiliser la taille d'un élément pointé par ce pointeur :

Sélectionnez
{
   int *p = malloc (sizeof *p);
}

Le changement de type se trouve largement simplifié :

Sélectionnez
{
   long *p = malloc (sizeof *p);
}

Quelques compléments sur malloc() et l'allocation dynamique en général dans l'article Description des mécanismes d'allocation dynamique de mémoire en langage C

XXX. Bien utiliser realloc()

La fonction realloc(), bien que souvent décriée pour sa lenteur, offre une alternative intéressante pour gérer des tableaux de taille variable. Bien sûr il ne faut pas allouer les objets un par un, mais par blocs (doublage, par exemple).

Pour utiliser correctement realloc(), quelques précautions doivent être prise. Par exemple :

Sélectionnez
#include <stdlib.h>

<...>
{
   /* allocation d'un tableau de 10 int
    * Pour pouvoir gérer la taille, on utilise
    * une variable 'taille'. Une structure comprenant
    * l'adresse du tableau et sa taille est aussi envisageable.
    */

   size_t size = 10;
   int *p = malloc (size * sizeof *p);

<...>

   /* Agrandissement du tableau a 15 int */
   {
      /* reallocation. Le résultat est stocke'
       * dans une variable temporaire
       */
      size = 15;
      type_s *p_tmp = realloc (p, size * sizeof *p_tmp);

      if (p_tmp != NULL)
      {
         /* si la nouvelle valeur est valide,
          * le pointeur original est mis a jour.
          */
         p = p_tmp;
      }
      else
      {
         /* l'ancien bloc est valide, mais il n'a pas été agrandi */
      }
   }
}

Il faut aussi garder à l'esprit que la partie nouvellement allouée n'est pas initialisée.

XXXI. Comment créer un tableau dynamique à deux dimensions ?

Il y a plusieurs façons de créer un tableau dynamique à deux dimensions de type T. La plus courante consiste à créer un tableau de N lignes contenant les adresses des N tableaux de M colonnes. L'avantage de cette méthode est qu'elle permet un usage habituel du tableau avec la notation [i][j].

Comme le tableau de N lignes contient des adresses, ses éléments sont donc des pointeurs sur T. Il se définit ainsi :

Sélectionnez
T* *pp = malloc (sizeof (T*) * N);

T représente le type d'un élément du tableau

Ensuite, chaque élément reçoit l'adresse du premier élément d'un tableau alloué de M colonnes. Chaque élément est donc de type T :

Sélectionnez
size_t i;
   for (i = 0; i < N; i++)
   {
      pp[i] = malloc (sizeof (T) * M);
   }

Bien sûr, pour une utilisation correcte, il faut en plus tenir compte du fait que malloc() peut échouer et qu'il faut libérer les blocs alloués après usage.

D'autre part, je rappelle que les valeurs d'un bloc fraîchement alloué sont indéfinies.

Enfin, selon les principes énoncés ici, on peut simplifier le codage comme ceci :

Sélectionnez
T **pp = malloc (sizeof *pp * N);
Sélectionnez
size_t i;
   for (i = 0; i < N; i++)
   {
      pp[i] = malloc (sizeof *pp[i] * M);
   }

ce qui facilite la maintenance et évite bien des erreurs de type, le choix étant confié au compilateur.

Il va sans dire qu'il faut ensuite libérer le tableau alloué selon le procédé inverse :

Sélectionnez
size_t i;
   for (i = 0; i < N; i++)
   {
     free(pp[i]), pp[i] = NULL;
   }
   free(pp), pp = NULL;
class="SectionNoteBasPage articleBody">

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • XXXII. Saisie de données par un opérateur (stdin)
    • XXXII-A. Introduction
    • XXXII-B. fgetc(), getc(), getchar()
    • XXXII-C. gets()
    • XXXII-D. scanf()
    • XXXII-E. fgets()
    • XXXII-F. Ressources
    • XXXII-G. Comment fonctionne fgetc(stdin) alias getchar()
      • XXXII-G-1. Comportement visible.
      • XXXII-G-2. Comportement interne.
      • XXXII-G-3. Quelques expérimentations.
  • XXXIII. Les fichiers
    • XXXIII-A. Introduction
    • XXXIII-B. Texte ou binaire ?
      • XXXIII-B-1. Fichier texte
      • XXXIII-B-2. Fichier binaire
      • XXXIII-B-3. Modes d'ouverture d'un fichier
      • XXXIII-B-4. Lecture d'un fichier
        • XXXIII-B-4-a. fgetc(), getc()
        • XXXIII-B-4-b. fread()
        • XXXIII-B-4-c. fscanf()
        • XXXIII-B-4-d. fgets()
          • XXXIII-B-4-d-i. Exemple d'utilisation
          • XXXIII-B-4-d-ii. Explication
          • XXXIII-B-4-d-iii. Critique de cet exemple
          • XXXIII-B-4-d-iv. Détection d'une erreur
          • XXXIII-B-4-d-v. Gestion des fins de ligne
          • XXXIII-B-4-d-vi. Exemple amélioré avec détection de la fin de lecture
          • XXXIII-B-4-d-vii. Explication
      • XXXIII-B-5. Écriture dans un fichier
        • XXXIII-B-5-a. fputc(), putc()
        • XXXIII-B-5-b. fwrite()
        • XXXIII-B-5-c. fprintf()
        • XXXIII-B-5-d. fputs()
      • XXXIII-B-6. Bien utiliser les formats de données
        • XXXIII-B-6-a. Format orienté texte
        • XXXIII-B-6-b. Format orienté binaire
    • XXXIII-C. Supprimer un enregistrement dans un fichier binaire
    • XXXIII-D. En guise de conclusion
  • XXXIV. Pourquoi fflush (stdout) ?

XXXII. Saisie de données par un opérateur (stdin)

XXXII-A. Introduction

Il est courant en C standard d'utiliser le flux stdin pour acquérir des données en provenance d'un opérateur. (Mode conversationnel). On admettra pour la suite que stdin est connecté à la partie 'clavier' d'un périphérique console.

Le langage C offre plusieurs fonctions permettant de lire des données sur un flux en général et sur stdin en particulier :

  • fgetc() ;
  • getc() ;
  • getchar() ;
  • gets() ;
  • scanf() ;
  • fgets().

XXXII-B. fgetc(), getc(), getchar()

Ces trois fonctions extraient un caractère du flux entrant (pour getchar(), ce flux est stdin). C'est insuffisant pour saisir autre chose qu'un simple <ENTER>. Ces fonctions ne sont absolument pas adaptées à la saisie d'un caractère comme un choix de menu par exemple.

Par contre, ces fonctions peuvent être utilisées pour construire des fonctions d'entrées de plus haut niveau plus ou moins spécialisées.

Détails de fonctionnement de fgetc()

XXXII-C. gets()

Pour des raisons évidentes de sécurité (pas de limitation du nombre de caractères saisis), la fonction gets() ne devrait pas être utilisée. Bien que, à ma connaissance, cette fonction ne soit pas officiellement dépréciée pour des raisons de compatibilité avec le code existant, il est fortement conseillé de ne pas l'utiliser pour de nouveaux développements.

XXXII-D. scanf()

Malgré ce que l'on constate dans l'abondante littérature consacrée à l'initiation au langage C, l'utilisation de scanf() n'est pas adaptée.

En effet, le 'f' de scanf() est là pour nous rappeler que l'entrée doit être formatée (formated), ce qui n'est évidemment pas le cas avec un opérateur humain qui peut entrer n'importe quoi. D'autre part, scanf() gère difficilement le '\n', ce qui entraîne des comportements aberrants dans les saisies si on ne prend pas certaines précautions d'usage.

L'utilisation correcte et sûre de scanf() est complexe, et n'est pas à la porté d'un débutant (ni même à celle de la plupart des programmeurs expérimentés). Néanmoins, il est possible d'utiliser correctement scanf() si on se forme correctement. En lisant par exemple l'article Scanf démythifiée.

XXXII-E. fgets()

Cette fonction est parfaitement adaptée à la saisie d'une ligne, (même de 1 caractère). Son usage est recommandé.

S'il faut saisir une valeur numérique, celle-ci sera d'abord saisie sous forme de ligne, puis traduite par la fonction appropriée (strtol(), strtoul(), strtod()) ou sscanf()) avec le filtre approprié :

Sélectionnez
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
   int ret;
   char temp[20];

   do
   {
      char saisie[20];

      printf("Entrez un nombre : ");
      fflush (stdout);

      fgets (saisie, sizeof saisie, stdin);

      /* Filtrage des caractères (entier décimal)
       * Nota : la saisie s'arrête à la première erreur.
       * Ce qui est saisi avant est considéré comme valide.
       *
       * "123a" -> "123" : ret = 1
       *
       * "a123" -> ""    : ret = 0
       */
      ret = sscanf (saisie, "%[0-9-]s", temp);
   }
   while (ret != 1);

   {
      long n = strtol (temp, NULL, 10);

      printf ("La chaîne est '%s', soit %ld\n", temp, n);
   }
   return 0;
}

D'autres exemples dans le chapitre sur les fichiers.

XXXII-F. Ressources

Comment se fabriquer des entrées solides en C

XXXII-G. Comment fonctionne fgetc(stdin) alias getchar()

Cette fonction d'apparence simple a en fait un comportement plus complexe qu'il n'y parait. En effet, elle regroupe un certain nombre de comportements non triviaux qui sont rarement expliqués dans la littérature C.

XXXII-G-1. Comportement visible.

L'appel de cette fonction provoque une suspension de l'exécution du programme. Durant cette suspension, il est possible de rentrer des caractères (par exemple à l'aide du clavier) et même éventuellement de supprimer le ou les derniers caractères saisis à l'aide de la touche 'BackSpace'. La fin de saisie (et la reprise de l'exécution du programme) est marquée par la frappe de la touche <enter>.

XXXII-G-2. Comportement interne.

Les caractères saisis sont stockés dans le flux stdin. Lorsque l'on frappe la touche <enter>, le caractère '\n' est aussi placé dans stdin, et l'exécution reprend. Le caractère le plus ancien est alors extrait du flux et il est retourné. En cas d'erreur de lecture ou d'entrée d'un caractère spécial dit 'de fin de fichier' (Ctrl-D, Ctrl-Z etc. selon le système), la valeur EOF (int < 0) est retournée.

Ensuite, si on rappelle fgetc(), deux cas sont possibles. Soit le flux est vide, soit il ne l'est pas. Si le flux est vide, la fonction fgetc() suspend l'exécution, et on retrouve le comportement précédent. S'il n'est pas vide, l'exécution n'est pas suspendue, et le caractère le plus ancien est extrait et retourné.

Dans la grande majorité des cas la lecture du '\n' signifie que la ligne saisie a été complètement lue.

XXXII-G-3. Quelques expérimentations.

A l'aide de simples programmes, il est possible de vérifier un certain nombre de comportements décrits précédemment :

Sélectionnez
#include <stdio.h>

int main (void)
{

   int x = fgetc(stdin);

   printf ("x = %d ('%c')\n", x, x);

   return 0;
}

Quelques essais de saisie :

Sélectionnez
<enter>
x = 10 ('
')

On voit que le caractère extrait est '\n' (ici, LF, soit le code ASCII 10)

Sélectionnez
a<enter>
x = 97 ('a')

On voit que le caractère extrait est 'a' (ici, le code ASCII 97). Le <enter> ('\n') n'a pas été extrait. Si on appelait fgetc() une nouvelle fois, il n'y aurait pas de suspension.

Sélectionnez
a<backspace>b<enter>
x = 98 ('b')

On constate que, bien que le premier caractère saisi fut 'a', le caractère extrait est 'b' (ici, le code ASCII 98). En effet, la touche <backspace> a permis de corriger la dernière saisie.

Sélectionnez
abcd<enter>
x = 97 ('a')

On voit que le caractère extrait est 'a', bien que d'autres caractères aient été saisis après. C'est donc bien le plus ancien caractère qui est extrait. Les autres caractères sont en attente de lecture. Une boucle de fgetc() permettrait de les extraire.

Sélectionnez
#include <stdio.h>

int main (void)
{
   int x;

   do
   {
      x = fgetc(stdin);
      printf ("x = %d ('%c')\n", x, x);
   }
   while (1);

   return 0;
}

Je laisse au lecteur le soin de refaire les expériences précédentes et d'en tirer les conclusions qui s'imposent.

XXXIII. Les fichiers

XXXIII-A. Introduction

Le langage C n'offre pas, à proprement parler, de gestion de fichiers. Il définit plutôt des flux d'entrées / sorties (I/O streams) sur lesquels il peut agir (ouverture/fermeture, lecture/écriture). L'unité d'information gérée par un flux est le byte.

Certains de ces flux sont connectés à des périphériques permettant par exemple de réaliser une interface entre la machine et l'utilisateur (IHM) en mode texte. Mais la plupart du temps, le nom associé au flux est en fait un 'fichier', c'est-à-dire une sorte de mémoire (disque, flash) accessible en écriture et en lecture par l'intermédiaire du système. L'avantage évident est que les données sont permanentes, même après mise hors tension de la machine.

En conséquence, dans la pratique, les termes flux et fichiers sont souvent confondus.

XXXIII-B. Texte ou binaire ?

Le langage C fait la distinction entre les fichiers binaires et les fichiers textes. Cette distinction est historique. Elle dépend en fait du système utilisé. Sur certains systèmes, il n'existe aucune différence physique entre les fichiers textes et les fichiers binaires. Sur d'autres systèmes, il existe une différence. Par souci de portabilité, il est recommandé de respecter cette distinction.

Le choix entre fichier texte ou binaire provient du contenu de ce fichier.

XXXIII-B-1. Fichier texte

On appelle fichier texte un fichier qui contient des informations de type texte, c'est à dire des séquences de lignes.

Une ligne est une séquence de caractères imprimables terminée par une marque de fin de ligne.

Selon le système, la marque de fin de ligne est composée de un ou plusieurs caractères de contrôle (par exemple, CR, LF, ou une séquence de ces caractères)

Sélectionnez
:----------------:--------------:----------------:
: Système        : Fin de ligne : Fin de fichier :
:----------------:--------------:----------------:
: Unix           :              :                :
: Mac X          : 0x0A LF      : Sans objet     :
: Linux          :              :                :
:----------------:--------------:----------------:
: Mac (non unix) : 0x0D CR      : Sans objet     :
:----------------:--------------:----------------:
: MS-DOS         : 0x0D CR      : 0x1A           :
: Windows        : 0x0A LF      : ^Z             :
: Windows NT     :              :                :
:----------------:--------------:----------------:
: VMS STREAM_CR  : 0x0D CR      : Sans objet     :
:----------------:--------------:----------------:
: VMS STREAM_LF  : 0x0A LF      : Sans objet     :
:----------------:--------------:----------------:
: VMS STREAM_CRLF: 0x0D CR      : Sans objet     :
:                : 0x0A LF      :                :
:----------------:--------------:----------------:

L'ensemble des valeurs numériques des caractères (charset) dépend du système. La plupart du temps, il s'agit du codage ASCII (0-127) avec des extensions plus ou moins standards au delà de 127. Il existe d'autres codes, comme EBCDIC utilisé sur certains mainframes IBM.

Pour écrire une fin de ligne dans un fichier texte, il suffit d'écrire le caractère '\n'. Celui-ci sera alors automatiquement traduit en marqueur de fin de ligne.

De même, lors de la lecture d'un fichier texte, le marqueur de fin de ligne est automatiquement traduit en '\n', quel qu'il soit.

Nota : Certains systèmes marquent la fin des fichiers textes d'un caractère spécial. Par exemple MS-DOS ajoute un code 26 (^Z). Cela signifie que, pour ce système, la lecture d'un fichier texte s'arrête dès la rencontre de ce caractère.

XXXIII-B-2. Fichier binaire

N'importe quel fichier, y compris un fichier texte, peut être considéré comme binaire. Dans ce cas, l'écriture et la lecture des caractères se fait sans interprétation.

Par exemple, sur une plate-forme utilisant le jeu de caractères ASCII, CR vaut 13 ou 0x0D ou '\r'. De même, LF vaut 10 ou 0x0A ou '\n'.

XXXIII-B-3. Modes d'ouverture d'un fichier

La fonction d'ouverture de fichier est fopen(). Comme pour les autres fonctions de gestion des fichiers, le fichier d'interface est <stdio.h>.

Sélectionnez
FILE *fopen (char const *filename, char const *mode);

Le mode d'ouverture est déterminé par une chaîne de caractère. Voici les chaînes correspondant aux principaux modes :

Sélectionnez
   "r"  : mode texte en lecture
   "w"  : mode texte en écriture (création)
   "a"  : mode texte en écriture (ajout)

   "rb" : mode binaire en lecture
   "wb" : mode binaire en écriture (création)
   "ab" : mode binaire en écriture (ajout)

XXXIII-B-4. Lecture d'un fichier

Le langage C offre plusieurs fonctions permettant de lire les données d'un fichier :

  • fgetc() ;
  • getc() ;
  • fread() ;
  • fscanf() ;
  • fgets().
XXXIII-B-4-a. fgetc(), getc()

Ces fonctions sont identiques. Elles permettent de lire un caractère.

XXXIII-B-4-b. fread()

Cette fonction permet de lire un bloc de caractères d'une longueur donnée. Elle est tout à fait adaptée à la lecture des données binaires brutes (non interprétées).

XXXIII-B-4-c. fscanf()

Cette fonction permet de lire des données 'texte' formatées. Cette fonction est d'une utilisation complexe et son usage est peu recommandé.

XXXIII-B-4-d. fgets()

Cette fonction permet de lire une ligne de texte. Elle est tout à fait adaptée à la lecture d'un fichier texte ligne par ligne.

Sa simplicité d'utilisation et sa robustesse en font la fonction préférée des programmeurs qui doivent analyser des fichiers textes.

XXXIII-B-4-d-i. Exemple d'utilisation

Soit le fichier texte :

Sélectionnez
Ceci est un simple fichier
texte de 2 lignes.

et un petit programme permettant de lire ces deux lignes :

Sélectionnez
/* fichier1.c */
#include <stdio.h>

int main (void)
{
   /* ouverture du fichier en mode texte */
   FILE *fp = fopen ("data.txt", "r");

   /* L'ouverture du fichier est-elle réalisée ? */
   if (fp != NULL)
   {
      /* définition d'un tableau de char destine a recevoir la ligne
       * La taille est arbitraire. Elle doit être cependant adaptée * aux besoins courants.
       * Pour les grandes tailles (disons > 256 char),
       * il est préférable d'utiliser une allocation dynamique.
       */
      char ligne[32];

      /* lecture de la première ligne */
      fgets (ligne, sizeof ligne, fp);

      /* Affichage de la première ligne */
      printf ("1: %s\n", ligne);

      /* lecture de la deuxième ligne */
      fgets (ligne, sizeof ligne, fp);

      /* Affichage de la deuxième ligne */
      printf ("2: %s\n", ligne);

      /* Fermeture du fichier */
      fclose (fp);
   }
   else
   {
      printf ("Erreur d'ouverture du fichier\n");
   }
   return 0;
}

On doit obtenir ceci sur la sortie standard (stdout) :

Sélectionnez
1: Ceci est un simple fichier

2: texte de 2 lignes.
XXXIII-B-4-d-ii. Explication▲

La ligne lue est stockée dans la variable ligne, y compris le '\n'. La fonction d'affichage printf() affiche le numéro de ligne, suivit de ': ', la ligne (avec son '\n') et un '\n' en plus, ce qui explique la présence de lignes "vides".

XXXIII-B-4-d-iii. Critique de cet exemple▲

Cet exemple de codage 'naïf' souffre d'un défaut majeur : Il fait l'hypothèse que le fichier fait 2 lignes, et il continue à lire le fichier même si une erreur de lecture s'est produite. En fait, tout simplement, il ne gère pas les erreurs de lecture.

Il est facile de gérer les erreurs de lecture. Toutes les fonctions de lecture retournent une valeur. Celle-ci peut prendre une valeur particulière qui signifie 'Arrêt de la lecture'. La cause n'est pas précisée. Ça peut être à cause d'une erreur (support en panne, données corrompu, fichier inexistant etc.) ou tout simplement par ce que la fin de fichier a été atteinte.

XXXIII-B-4-d-iv. Détection d'une erreur

La fonction fgets() retourne une valeur de type char *. Si la lecture a réussi, la valeur retournée est l'adresse du tableau de char passé en paramètre. En cas d'échec, la valeur NULL est retournée. Il suffit donc de surveiller cette valeur pour savoir si on peut continuer ou non. Comme une des causes d'échec est la "fin de fichier atteinte", on peut donc parfaitement intégrer ce test dans une boucle de lecture "ligne par ligne".

Une fois l'échec de la lecture constaté, il est possible d'en identifier la cause. Le langage C met à disposition les deux fonctions feof() et ferror() qu'il faut appeler après la boucle de lecture, mais avant la fermeture du fichier.

Sélectionnez
     while (fonction_de_lecture(fp) != ERREUR)
   {
      ...
   }

   if (feof(fp))
   {
      /* la fin de fichier a été détectée */
      puts ("EOF");
   }

   if (ferror(fp))
   {
      /* une erreur s'est produite */
      perror (NOM_DU_FICHIER);
   }

   fclose (fp);
XXXIII-B-4-d-v. Gestion des fins de ligne

On constate que lorsque fgets() lit une ligne entière, un '\n' se retrouve à la fin de la chaîne saisie. La présence de '\n' est gênante ou non selon l'application.

Ceci dit, dans tous les cas, il est conseillé d'en détecter la présence. En effet, sa présence indique que la ligne a été lue entièrement, alors que son absence indique que la ligne a été tronquée, et que d'autres caractères (au minimum un '\n') attendent pour être lus. Il est donc conseillé d'écrire ces quelques lignes après un fgets() pour clarifier la situation :

Sélectionnez
#include <stdio.h>
#include <string.h>
   ...
{
   char ligne[123];

   /* test d'erreur omis */
   fgets (ligne, sizeof ligne, fp);

   {
      /* chercher le '\n' */
      char *p = strchr(ligne, '\n');

      if (p != NULL)
      {
         /* si on l'a trouve, on l'elimine. */
         *p = 0;
      }
      else
      {
         /* Le traitement depend de l'application.
          * Par exemple, ici, on choisi d'ignorer
          * les autres caracteres.
          */

         /* sinon, on lit tous les caracteres restants */
         int c;

         while ((c = fgetc(fp)) != '\n' && c != EOF)
         {
         }
      }
   }
}

Il est clair que dans la pratique, l'ensemble de ce code devra être intégré dans une fonction unique de lecture d'une ligne à partir d'un flux.

XXXIII-B-4-d-vi. Exemple amélioré avec détection de la fin de lecture
Sélectionnez
/* fichier2.c */
#include <stdio.h>

int main (void)
{
   FILE *fp = fopen ("data.txt", "r");

   if (fp != NULL)
   {
      char ligne[32];

      /* définition d'un compteur de lignes et initialisation */
      int cpt = 0;

      /* lecture des lignes */
      while (fgets (ligne, sizeof ligne, fp) != NULL)
      {
         /* Mise a jour du compteur */
         cpt++;

         /* Affichage des lignes */
         printf ("%d: %s\n", cpt, ligne);
      }

      /* On peut ajouter ici la détection de la cause
       * de l'erreur décrite ci-dessus
       */

      fclose (fp);
   }
   else
   {
      printf ("Erreur d'ouverture du fichier\n");
   }

   return 0;
}

Cet exemple met en œuvre un mécanisme qui s'adapte automatiquement au nombre de lignes du fichier. Cependant, attention, le fonctionnement, bien qu'il reste sûr, risque d'être surprenant si la longueur de la ligne est supérieure à celle du tableau 'ligne'.

Par exemple, si on diminue la taille de 'ligne' à 16 au lieu de 32,

Sélectionnez
<...>
   char ligne[16];
<...>

on obtient :

Sélectionnez
1: Ceci est un sim
2: ple fichier

3: texte de 2 lign
4: es.
XXXIII-B-4-d-vii. Explication▲

Rappelons que la taille du tableau de char a été transmise à la fonction fgets().

Celle-ci tente de lire la ligne, mais celle-ci est trop longue pour tenir dans le variable 'ligne'. fgets(), qui connaît la taille de la variable 'ligne', applique alors une stratégie d'adaptation qui consiste à stocker ce qui est possible dans la variable, en laissant une place pour le 0 final. En effet, fgets() a pour obligation de produire une chaîne de caractères valide dans tous les cas.

C'est pourquoi la première ligne est partiellement lue ainsi :

Sélectionnez
0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15 : Indice
'C' 'e' 'c' 'i' ' ' 'e' 's' 't' ' ' 'u' 'n' ' ' 's' 'i' 'm'  0 : Données

Mais les caractères manquants ne sont pas perdus, et ils sont lus par l'appel suivant :

Sélectionnez
0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15 : Indice
'p' 'l' 'e' ' ' 'f' 'i' 'c' 'h' 'i' 'e' 'r' '\n' 0             : Données

Cette fois, la place est suffisante, et l'ensemble de la chaîné est lue, y compris le '\n'.

XXXIII-B-5. Écriture dans un fichier

Le langage C offre plusieurs fonctions permettant d'écrire des données dans un fichier :

  • fputc() ;
  • putc() ;
  • fwrite() ;
  • fprintf() ;
  • fputs().
XXXIII-B-5-a. fputc(), putc()

Ces fonctions sont identiques. Elles permettent d'écrire un caractère.

XXXIII-B-5-b. fwrite()

Cette fonction permet d'écrire un bloc de caractères d'une longueur donnée. Elle est tout à fait adaptée à l'écriture de données binaires brutes (non interprétées).

XXXIII-B-5-c. fprintf()

Cette fonction permet d'écrire des données 'texte' formatées. Elle comporte de nombreuses possibilités de conversion de valeurs numériques en texte. (Entiers, flottants etc.)

XXXIII-B-5-d. fputs()

Cette fonction permet d'écrire une chaîne de caractères.

XXXIII-B-6. Bien utiliser les formats de données

Il n'est pas rare que des données enregistrées dans un fichier par une machine soient lues par une autre machine, ou par autre programme ou par le même programme mais compilé avec des options différentes. Pour pouvoir récupérer les données, il faut qu'en aucun cas, le format des données enregistrées ne dépende de l'implémentation.

XXXIII-B-6-a. Format orienté texte

Le format texte est un bon choix, car il utilise une séquence de caractères simple et évidente (chronologique) et un codage très répandu (ASCII). Il peut y avoir quelques problèmes de transcodage pour les valeurs de 128 à 255 (ANSI, OEM etc.), mais rien qui ne soit insurmontable. D'autre part, la conversion ASCII/EBCDIC est triviale.

Il subsiste le problème des fins de ligne qui sont différentes d'un système à l'autre. Il existe des utilitaires bien connus (dos2unix, unix2dos etc.) généralement fournis avec ces systèmes qui font les conversions. Rappelons que la fonction system() permet d'appeler une commande extérieure. Si néanmoins, cet utilitaire n'existait pas, il serait facile de le faire soi-même. Bien sûr, il faudrait travailler en mode binaire de façon à contrôler les données du fichier de manière 'brute' (raw).

Les chaînes et les valeurs numériques sont encodées et éventuellement formatées avec fprintf(). Une organisation en ligne est souhaitable. Elles sont ensuite lues ligne par ligne avec fgets() et analysées soit par strtol(), strtoul() ou strtod() pour les cas les plus simples (valeurs numériques pures), soit par sscanf() pour les cas plus complexes, à condition que le formatage soit clairement défini. Il est souhaitable d'utiliser des formats simples à analyser et surtout sans ambiguïté quant aux séparateurs. Le format CSV est recommandé.

XXXIII-B-6-b. Format orienté binaire

Une mauvaise utilisation des formats binaires (raw) peut apporter des problèmes de portabilité. Il est recommandé d'utiliser des formats indépendants comme XDR (RFC 1832).

XXXIII-C. Supprimer un enregistrement dans un fichier binaire

Pour supprimer un enregistrement, le plus simple est de procéder ainsi :

  • le fichier original est ouvert en lecture. Un nouveau fichier est ouvert en écriture. L'original est lu enregistrement par enregistrement (fread()), et recopié dans le nouveau fichier (fwrite()) en omettant l'enregistrement à supprimer (if …) ;
  • par un jeu subtil de suppression et de renommage (remove(), rename()), on se retrouve avec une copie de l'original (genre .old ou .bak) et le nouveau fichier qui a maintenant le nom de l'ancien. L'opération reste simple, et a l'avantage de permettre l'annulation (par renommage de l'ancien fichier).

Toute autre opération basée sur l'écriture/lecture dans le même fichier est dangereuse, non portable et se traduit souvent par la destruction du fichier original sans recours possible.

XXXIII-D. En guise de conclusion

Il ne faut pas se tromper d'outil. Les flux du C sur disque sont très pratiques pour enregistrer quelques données statiques dans un fichier texte. En binaire, c'est déjà plus risqué à moins de passer par un format indépendant comme XDR. Pour gérer des enregistrements, les fichiers C sont trop rustiques. Il faut une véritable base de données (comme SQLite ou MySQL par exemple).

XXXIV. Pourquoi fflush (stdout) ?

Il arrive parfois de rencontrer ce genre de code…

Sélectionnez

printf("Entrez un nombre : ");
fflush (stdout);

… et on se demande alors à quoi peut bien servir ce fflush (stdout).

Le printf() précédent envoie une chaîne de caractères à stdout. Or cette chaîne n'est pas terminée par un '\n'.

Il faut savoir que stdout est souvent un flux "bufferisé", ce qui signifie, en bon français, que les caractères sont placés dans un tampon (buffer) de sortie avant d'être réellement émis.

Il y a trois critères qui déclenchent l'émission réelle des caractères :

  • le tampon d'émission est plein (incontrôlable) ;
  • un '\n' a été placé dans le tampon[1] ;
  • la commande de forçage a été activée.

La commande de forçage est activée par l'appel de la fonction fflush (stdout), ce qui explique sa présence dans le code mentionné.

[1] sauf en cas de redirection dudit flux vers un fichier.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Table des matières

  • XXXV. <time.h> : La gestion du temps
    • XXXV-A. Introduction
    • XXXV-B. Usage
      • XXXV-B-1. Lire l'heure courante
      • XXXV-B-2. Afficher l'heure courante
  • XXXVI. <time.h> : bien utiliser difftime()
  • XXXVII. rand(), srand()... j'y comprends rien...

XXXV. <time.h> : La gestion du temps

XXXV-A. Introduction

Le langage C fournit un certain nombre de fonctions permettant de lire l'heure/date courante et de convertir la valeur lue en structure ou en chaîne de caractères. Ces éléments sont définis et déclarés dans le header standard <time.h>

Élément | Nature | Description
struct tm | structure | Structure de date et heure
time() | fonction | Lecture de l'heure courante
time_t | type | Type retourné par time()
localtime() | fonction | Charge la structure tm avec l'heure locale
gmtime() | fonction | Charge la structure tm avec l'heure GMT
mktime() | fonction | Normalise la structure tm Retourne le temps en time_t
strftime() | fonction | Crée une chaîne formatée à partir d'une structure tm
difftime() | fonction | Calcule la différence entre deux dates

XXXV-B. Usage

XXXV-B-1. Lire l'heure courante

On utilise la fonction time(). On peut soit passer l'adresse d'un time_t, soit récupérer la valeur dans un time_t. Si on utilise pas l'adresse d'un time_t, il faut le préciser à la fonction en passant NULL :

Sélectionnez
#include <time.h>

int main (void)
{
   time_t ta;
   time_t tb;

   time (&ta);
   tb = time (NULL);

   return 0;
}

Telle quelle, la valeur contenue dans ta et tb n'a pas grande signification. La norme dit que c'est un grand entier qui représente un nombre d'élément de temps depuis une date donnée. Sous POSIX.1, c'est un nombre de secondes depuis le 1er janvier 1970. (Epoch). En principe, on ne manipule pas la valeur directement, mais via sa représentation en struct tm.

Cette valeur a quand même un intérêt pratique. Il y a de fortes chances qu'elle soit différente d'un lancement de programme à l'autre. On l'utilise donc couramment pour initialiser le générateur pseudo-aléatoire du C avec une valeur différente à chaque lancement :

Sélectionnez
srand((unsigned) time(NULL));

ce qui permet de démarrer le cycle de la séquence pseudo-aléatoire à un endroit difficilement prévisible, renforçant ainsi l'effet 'aléatoire' pour les jeux et autres programmes statistiques.

XXXV-B-2. Afficher l'heure courante

L'affichage de la date ou de l'heure de fait grace à la fonction strftime() qui a été conçue pour ça. Elle attend l'adresse d'une structure tm, qu'il va donc falloir mettre à jour au préalable. On utilise pour ça la valeur retournée par time(), qui donne la datation courante sous la forme d'un entier et une des fonction localtime() ou gmtime() selon que l'on veut convertir en heure locale ou en heure GMT (Greenwich). En France, il y a une heure de différence.

L'usage précis de strftime() et de ses nombreux paramètres de formatage doit être étudié dans un document de référence.

Sélectionnez
#include <stdio.h>
#include <time.h>

int main (void)
{
   /* lire l'heure courante */
   time_t now = time (NULL);

   /* la convertir en heure locale */
   struct tm tm_now = *localtime (&now);

   /* Créer une chaîne JJ/MM/AAAA HH:MM:SS */
   char s_now[sizeof "JJ/MM/AAAA HH:MM:SS"];

   strftime (s_now, sizeof s_now, "%d/%m/%Y %H:%M:%S", &tm_now);

   /* afficher le résultat : */
   printf ("'%s'\n", s_now);

   return 0;
}
Sélectionnez
'29/01/2009 02:34:00'

Process returned 0 (0x0)   execution time : 0.054 s
Press any key to continue.

XXXVI. <time.h> : bien utiliser difftime()

Les fonctions de <time.h> offrent une interface assez complexe. Voici un exemple qui rassemble l'usage de la plupart de ces fonctions.

Sélectionnez
#include <stdlib.h>
#include <stdio.h>
#include <time.h>

int main (void)
{
   time_t now = time (NULL);
   struct tm tm_now = *localtime (&now);
   char s[64];

   strftime (s, sizeof s, "%d/%m/%Y", &tm_now);
   printf ("Aujourd'hui : %s\n", s);

   /* prochain Noel */
   {
      struct tm tm_xmas =
      {0};

      tm_xmas.tm_year = tm_now.tm_year;
      tm_xmas.tm_mon = 12 - 1;
      tm_xmas.tm_mday = 25;

      /* ajustement */
      {
         time_t xmas = mktime (&tm_xmas);

         strftime (s, sizeof s, "%d/%m/%Y", &tm_xmas);
         printf ("Noel : %s\n", s);

         {
            time_t diff = difftime (xmas, now);
            struct tm tm_diff = *gmtime (&diff);

            printf ("Plus que %d jours avant Noel\n", tm_diff.tm_yday);
         }
      }
   }

   return 0;
}

XXXVII. rand(), srand()... j'y comprends rien...

Un générateur pseudo-aléatoire est une machine qui génère une séquence de nombres déterminée, cyclique, mais difficile à prévoir pour un humain. De plus, la répartition des valeurs (histogramme) est supposée être équilibrée.

La génération n'est pas 'spontanée', mais 'à la demande' (par appel de la fonction rand()). Les valeurs produites sont comprises entre 0 et RAND_MAX inclus.

A chaque fois que l'on appelle rand(), une nouvelle valeur sort :

Sélectionnez
#include <stdio.h>
#include <stdlib.h>

int main (void)
{
   int i;
   printf ("Les valeurs vont de 0 a %d\n", RAND_MAX);
   for (i = 0; i < 10; i++)
   {
      int val = rand ();
      printf ("%d ", val);
   }
   printf ("\n");
   return 0;
}

Par exemple :

Sélectionnez
Les valeurs vont de 0 à 32767
41 18467 6334 26500 19169 15724 11478 29358 26962 24464

Mais on constate que si on lance le programme plusieurs fois, la séquence est toujours la même.

On peut modifier l'origine de la séquence avec srand(), en passant une valeur comprise entre 0 et RAND_MAX. Par exemple 10 :

Sélectionnez
#include <stdio.h>
#include <stdlib.h>

int main (void)
{
   int i;

   srand(10); /* MODIF */

   printf ("Les valeurs vont de 0 a %d\n", RAND_MAX);
   for (i = 0; i < 10; i++)
   {
      int val = rand ();
      printf ("%d ", val);
   }
   printf ("\n");
   return 0;
}

On constate que les valeurs sont différentes du tirage précédent, mais que si on relance le programme, elles restent identiques :

Sélectionnez
Les valeurs vont de 0 à 32767
71 16899 3272 13694 13697 18296 6722 3012 11726 1899

Pour avoir une séquence différente à chaque lancement du programme, il faut donc trouver un moyen de passer une valeur 'changeante' à srand(), d'où l'idée d'utiliser la valeur retournée par time(), qui change une fois par seconde, indépendamment du programme (c'est une valeur gérée par le système).

Une seconde, c'est long, mais ça suffit pour les besoins courants.

Sélectionnez
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main (void)
{
   int i;

   srand((unsigned) time(NULL)); /* MODIF */

   printf ("Les valeurs vont de 0 a %d\n", RAND_MAX);
   for (i = 0; i < 10; i++)
   {
      int val = rand ();
      printf ("%d ", val);
   }
   printf ("\n");
   return 0;
}

Si on lance plusieurs fois le programme (au moins une seconde entre chaque lancement), on obtient maintenant des séquences différentes :

Sélectionnez
25985 16903 23861 29724 17917 23752 17039 25712 20507 30816
26197 27421 5384 20976 236 16846 22741 32047 12417 24408
26433 14874 13653 16830 21990 4658 17461 17892 21603 7731

etc. Je précise que pour que les valeurs changent dans le programme, il faut évidemment que srand() ne soit appelé qu'une seule fois au début du programme.

Après, il y a des astuces arithmétiques pour réduire la plage de valeurs... C'est du bête calcul entier... Détails dans la FAQ de f.c.l.c., notamment ici. D'autre part, ceci peut aider.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique


Notes sur le langage C

Table des matières

XXXVIII. Du bon usage de qsort()

XXXVIII-A. Description de la fonction qsort()

XXXVIII-A-1. Introduction

La fonction qsort() implémente un algorithme de tri non spécifié qui permet de trier tout ou partie de n'importe quel tableau de données, du moment qu'il existe un critère de tri dans les données. Elle s'appuie sur une fonction utilisateur qui se charge d'exprimer le critère de tri.

C'est l'implémentation qui décide quel algorithme est utilisé. En général, l'algorithme est choisi pour sa performance. Il n'est pas rare que ce soit un Quick Sort.

XXXVIII-A-2. Interface

Le prototype de la fonction qsort() est

Sélectionnez
void qsort (void *tableau
            , size_t nb_elem
            , size_t taille_elem
            , int (*compare) (void const *a, void const *b));

Les paramètres sont :

  • void *tableau : adresse du premier élément du tableau à trier. La partie à trier doit être modifiable ;
  • size_t nb_elem : nombre d'éléments du tableau à trier (à ne pas confondre avec sa taille). Par exemple, tout le tableau : sizeof tab / sizeof *tab ;
  • size_t taille_elem : taille d'un élément du tableau : sizeof *tab ;
  • int (*compare) (void const *a, void const *b) : adresse de la fonction de comparaison (pointeur de fonction). Cette fonction est fournie par l'utilisateur.
XXXVIII-A-2-a. Interface de la fonction de comparaison

Cette fonction dispose de deux paramètres :

  • void const *a : adresse d'un des éléments du tableau en cours d'évaluation par l'algorithme. Il est qualifié 'const', car la fonction de comparaison ne doit en aucun cas en modifier le contenu sous peine de comportement indéfini ;
  • void const *b : adresse d'un autre élément du tableau en cours d'évaluation par l'algorithme. Il est qualifié 'const', car la fonction de comparaison ne doit en aucun cas en modifier le contenu sous peine de comportement indéfini.

de plus, elle doit retourner un int qui prend la valeur suivante (tri croissant) :

  • 0 si le critère de a est égal au critère de b ;
  • < 0 si le critère de a est inférieur au critère de b ;
  • > 0 si le critère de a est supérieur au critère de b.

XXXVIII-A-3. Comportement

La fonction qsort() effectue le tri du tableau selon le critère indiqué. La valeur retournée par la fonction de comparaison permet à l'algorithme de prendre les décisions qui s'imposent.

XXXVIII-A-3-a. Comportement de la fonction de comparaison

La fonction de comparaison reçoit l'adresse des deux éléments en cours d'évaluation. Par intermédiaire de pointeurs locaux typés et initialisés avec ces paramètres, elle doit évaluer le critère de tri et retourner un int de la valeur résultant de l'évaluation. Pour un entier, une simple différence suffit. Pour une chaîne, str[n]cmp() a été conçue pour ça. Pour un réel, c'est plus délicat, car la notion d'égalité est soumise à l'appréciation d'un EPSILON qui dépend de la précision recherchée.

XXXVIII-B. Usage de la fonction qsort()

Afin de dédramatiser l'usage de qsort(), qui fait parfois un peu peur, je fournis ici quelques exemples d'utilisation.

Attention, je suppose que les bases du C sont connues (tableaux, pointeurs, fonctions, I/O)

Ces exemples ne sont pas des suites de validation d'algorithmes de tri ! Ce ne sont que de simples exemples.

XXXVIII-B-1. Tri d'un tableau d'entiers

Soit un tableau de 5 entiers à trier : 4 , 6, -3, 4, 7,

Sélectionnez
#include <stdio.h>
#include <stdlib.h>

/* affichage du tableau */
static void aff (int *a, size_t n)
{
   size_t i;
   for (i = 0; i < n; i++)
   {
      printf ("=", a[i]);
   }
   printf ("\n");
}

/* fonction utilisateur de comparaison fournie a qsort() */
static int compare (void const *a, void const *b)
{
   /* définir des pointeurs typés et initialisés
      avec les parametres */
   int const *pa = a;
   int const *pb = b;

   /* évaluer et retourner l'état de l'évaluation (tri croissant) */
   return *pa - *pb;
}

int main (void)
{
/* tableau a trier */
   int tab[] = { 4, 6, -3, 4, 7 };

/* affichage du tableau dans l'état */
   aff (tab, sizeof tab / sizeof *tab);

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, compare);

/* affichage du tableau après le tri */
   aff (tab, sizeof tab / sizeof *tab);

   return 0;
}

Ce qui donne :

Sélectionnez
  4  6 -3  4  7
 -3  4  4  6  7

Press ENTER to continue.

XXXVIII-B-2. Tri d'un tableau de chaînes

Il s'agit maintenant de trier un tableau de chaînes constantes. Ici, il est particulièrement important de bien comprendre le sens de 'const'. Le tableau est modifiable, mais il est composé de pointeurs sur des chaînes non modifiables. Ce que va faire qsort(), c'est de réorganiser le tableau de pointeurs de façon à ce lorsqu'on lira le tableau en séquence, les chaînes pointées apparaissent comme 'triées'. Ce concept est assez subtil, car le tri est indirect. En effet, si on regardait la valeur des pointeurs dans le tableau, ils ne seraient probablement pas triés. De même, les chaînes en mémoire non modifiable sont, par définition, restées à leur place.

Dans la fonction de comparaison, comme d'habitude, on récupère les adresse des éléments en cours d'évaluation. Ici, les éléments sont de type char const *. Leur adresse est donc de type char const * *. Mais comme on a pas le droit de modifier les données pointées, on doit ajouter un 'const' pour être conforme aux paramètres : char const * const *

Sélectionnez
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* affichage du tableau */
static void aff (char const **a, size_t n)
{
   size_t i;
   for (i = 0; i < n; i++)
   {
      printf ("%s\n", a[i]);
   }
   printf ("\n");
}

/* fonction utilisateur de comparaison fournie a qsort() */
static int compare (void const *a, void const *b)
{
   /* définir des pointeurs typés et initialisés
      avec les paramètres */
   char const *const *pa = a;
   char const *const *pb = b;

   /* évaluer et retourner l'état de l'évaluation (tri croissant) */
   return strcmp (*pa, *pb);
}

int main (void)
{
/* tableau a trier (tableau de pointeurs sur char const) */
   char const *tab[] = { "world", "hello", "wild" };

/* affichage du tableau dans l'état */
   aff (tab, sizeof tab / sizeof *tab);

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, compare);

/* affichage du tableau après le tri */
   aff (tab, sizeof tab / sizeof *tab);

   return 0;
}

malgré tout, le tri a eu lieu :

Sélectionnez
world
hello
wild

hello
wild
world

Press ENTER to continue.

XXXVIII-B-3. Tri d'un tableau de structures

Soit une structure comprenant {nom, prénom, age}. On crée un tableau de 5 éléments de ce type que l'on remplit "à la main", puis, on fait un tri par prénom, par age décroissant, puis par age croissant.

Sélectionnez
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct fiche
{
   char nom[11];
   char prenom[11];
   int age;
};

/* affichage du tableau */
static void aff (struct fiche const *a, size_t n)
{
   size_t i;
   for (i = 0; i < n; i++)
   {
      /* pointeur intermédiaire pour alléger l'écriture */
      struct fiche const *p = a + i;
      printf ("%-10s %-10s %d ans\n", p->nom, p->prenom, p->age);
   }
   printf ("\n");
}

/* fonction utilisateur de comparaison fournie a qsort() */
static int compare_prenom (void const *a, void const *b)
{
   /* définir des pointeurs typés et initialisés
      avec les paramètres */
   struct fiche const *pa = a;
   struct fiche const *pb = b;

   /* évaluer et retourner l'état de l'évaluation (tri croissant) */
   return strcmp (pa->prenom, pb->prenom);
}

/* fonction utilisateur de comparaison fournie a qsort() */
static int compare_age (void const *a, void const *b)
{
   struct fiche const *pa = a;
   struct fiche const *pb = b;

   return pa->age - pb->age;
}

/* fonction utilisateur de comparaison fournie a qsort() */
static int compare_age_dec (void const *a, void const *b)
{
   struct fiche const *pa = a;
   struct fiche const *pb = b;

   return pb->age - pa->age;
}

int main (void)
{
/* tableau à trier (tableau de pointeurs sur char const) */
   struct fiche tab[] = {
      {"Simpson", "Homer", 36},
      {"Bouvier", "Marge", 34},
      {"Simpson", "Bart", 10},
      {"Simpson", "Lisa", 8},
      {"Simpson", "Maggie", 2},
   };

/* affichage du tableau dans l'état */
   aff (tab, sizeof tab / sizeof *tab);

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, compare_prenom);

/* affichage du tableau après le tri */
   aff (tab, sizeof tab / sizeof *tab);

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, compare_age);

/* affichage du tableau après le tri */
   aff (tab, sizeof tab / sizeof *tab);

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, compare_age_dec);

/* affichage du tableau après le tri */
   aff (tab, sizeof tab / sizeof *tab);

   return 0;
}
Sélectionnez
Simpson    Homer      36 ans
Bouvier    Marge      34 ans
Simpson    Bart       10 ans
Simpson    Lisa       8 ans
Simpson    Maggie     2 ans

Simpson    Bart       10 ans
Simpson    Homer      36 ans
Simpson    Lisa       8 ans
Simpson    Maggie     2 ans
Bouvier    Marge      34 ans

Simpson    Maggie     2 ans
Simpson    Lisa       8 ans
Simpson    Bart       10 ans
Bouvier    Marge      34 ans
Simpson    Homer      36 ans

Simpson    Homer      36 ans
Bouvier    Marge      34 ans
Simpson    Bart       10 ans
Simpson    Lisa       8 ans
Simpson    Maggie     2 ans

Press ENTER to continue.

XXXVIII-B-4. Tri de nombres réels title="Haut de page"

Pour trier les nombres réels (float, double), il y a quelques précautions à prendre, car la notion d'égalité n'existe pas. Il faut travailler par plages :

Sélectionnez
#include <stdio.h>
#include <time.h>

double random_range (double min, double max)
{
   return min + ((max - min) * (rand () / (double) RAND_MAX));
}

static int cmp (void const *a, void const *b)
{
   int ret = 0;
   double const *pa = a;
   double const *pb = b;
   double diff = *pa - *pb;
   if (diff > 0)
   {
      ret = 1;
   }
   else if (diff < 0)
   {
      ret = -1;
   }
   else
   {
      ret = 0;
   }

   return ret;
}

int main (void)
{
   double tab[100];
   int i;
   srand ((int) time (NULL));
   for (i = 0; i < sizeof tab / sizeof *tab; i++)
   {
      tab[i] = random_range (0, 1);
   }

   qsort (tab, sizeof tab / sizeof *tab, sizeof *tab, cmp);

   for (i = 0; i < sizeof tab / sizeof *tab; i++)
   {
      printf ("%8.4f", tab[i]);
   }

   return 0;
}

XXXIX. Les identificateurs réservés title="Haut de page"

Le document de définition du langage C a réservé un certain nombre d'identificateurs réservés. Ils peuvent être utilisés par les implémenteurs (ceux qui écrivent les compilateurs) ou pour des extensions du langage.

Ces identificateurs peuvent apparaître dans les interfaces publiques ou pour réaliser certaines parties des fichiers d'entête (macros, paramètres...). Ils sont choisis de façon à ne pas interférer avec les identificateurs des utilisateurs, sous réserve, bien sûr, que ceux-ci ne les utilisent pas, d'où l'intérêt de cet article.

Les identificateurs réservés sont :

Sélectionnez
+---------------------+----------------+---------------------+----------------+
 | Les identificateurs |                | Exemple             | Exemple        |
 | commençant par      | suivi de       | valide              | Réservé        |
 +---------------------+----------------+---------------------+----------------+
 | "_"                 | "_A-Z"         | _123                | _ABC           |
 +---------------------+----------------+---------------------+----------------+
 | "is"                | "a-z"          | is_abc              | isabc          |
 +---------------------+----------------+---------------------+----------------+
 | "mem"               | "a-z"          |                     |                |
 +---------------------+----------------+---------------------+----------------+
 | "str"               | "a-z"          |                     |                |
 +---------------------+----------------+---------------------+----------------+
 | "to"                | "a-z           |                     |                |
 +---------------------+----------------+---------------------+----------------+
 | "wcs"               | "a-z"          |                     |                |
 +---------------------+----------------+---------------------+----------------+
 | "E"                 | "A-Z" ou "0-9" | Eabc                | E123 EABC      |
 +---------------------+----------------+---------------------+----------------+
 | "LC_"               | "A-Z"          | LC_abc LC_123 LCABC | LC_ABC         |
 +---------------------+----------------+---------------------+----------------+
 | "SIG"               | "_A-Z"         | SIGabc              | SIGABC SIG_ABC |
 +---------------------+----------------+---------------------+----------------+

XL. Bien gérer la portée des objets et des fonctions

Le langage C offre par nature un contrôle assez fin de la portée des objets et des fonctions. Cette caractéristique est souvent mal connue, pourtant elle apporte un bénéfice certain, notamment sur le plan de l'organisation du code (conception détaillée).

XL-A. Fonctions

Par défaut, la portée d'une fonction est globale.

Sélectionnez
int function (int a, char *b)
{
}

Elle est visible d'un autre module une simple déclaration :

Sélectionnez
int function ();

ou mieux, un prototype :

Sélectionnez
int function (int a, char *b);

Il est possible cependant de réduire la portée de la fonction à l'unité de compilation dans laquelle elle a été définie, en ajoutant le qualificateur static.

Sélectionnez
static int function (int a, char *b)
{
}

Cette pratique, lorsqu'elle est possible, apporte différents avantages :

  • une économie d'identificateurs. La portée de celui-ci étant limitée à une unité de compilation, il est possible de le réutiliser pour une autre fonction qualifiée 'static' dans une autre unité de compilation ;
  • une meilleure optimisation. Certains compilateurs sont capables d'"inliner" une telle fonction dans certaines conditions, ce qui diminue le temps d'exécution au prix d'une augmentation de la taille (le code de la fonction est recopié autant de fois que nécessaire) ;
  • une meilleure organisation du code. Etant donné que ces fonctions sont forcément appelées par une fonction de l'unité de compilation dans laquelle elles ont été définies, le code se trouve naturellement organisé en blocs fonctionnels cohérents. De plus, comme à priori, ces fonctions n'ont pas besoin de prototypes séparés, cela favorise une organisation du fichier source selon le principe 'Définir avant d'utiliser' (Top-down)

On évitera cependant de multiplier les codes identiques, et les principes de factorisation du code restent en vigueur.

Sélectionnez
+--------------------+    +--------------------+
| Bloc fonctionnel A |    | Bloc Fonctionnel B |
+--------------------+    +--------------------+
               |               |
               v               v
             +--------------------+
             |       Outils       |
             +--------------------+

XL-B. Objets class="HautPage"

La portée d'un objet est régie selon plusieurs critères.

XL-B-1. Définition hors d'un bloc

La portée par défaut est globale. Elle peut être réduite à l'unité de compilation en ajoutant le qualificateur static.

XL-B-2. Définition dans un bloc

Si deux objets ont le même nom, l'objet de portée inférieure masque les objets de portée supérieure. Pour cette raison, qui entraîne un comportement confus, on évite de donner le même nom à des objets dont les portées sont imbriquées.

La portée est celle du bloc et des blocs inclus.

XL-B-3. Masquage (Shadowing)

Sélectionnez
/* objet de portée globale */
int x;

int f (void)
{
   /* la globale x est masquée par une locale du même nom */
   int x = 0;

   /* la globale x n'est pas modifiée. */
   x++;
}

int main (void)
{
   /* la globale x est modifiée */
   x = 2;

   f();

   /* la globale x vaut toujours 2 */

   return 0;
}

XLI. Du bon usage de assert()

assert() est une macro qui permet de 'poser un piège'. On s'en sert en phase de mise au point pour vérifier si la conception et la réalisation sont correctes.

Le paramètre est une expression. Si elle retourne 0 (expression fausse), le programme s'arrête et un message indiquant le lieu et la cause est affiché.

Il est d'usage qu'en mode production (release), la macro globale NDEBUG soit définie, ce qui fait que les macros assert(), bien que toujours présentes dans le source, ne génèrent plus aucun code de vérification. En conséquence, cette macro ne doit évidemment pas être utilisée pour détecter des erreurs d'utilisation ou de système.

XLI-A. Exemple d'utilisation

Sélectionnez
#include <stdio.h>

static void afficher (int const t[], size_t n)
{
   size_t i;
   for (i = 0; i <= n; i++)
   {
      printf ("M", t[i]);
   }
   printf ("\n");
}

int main (void)
{
   int tab[] = {1, 2, 3, 4};

   afficher (tab, sizeof tab / sizeof *tab);
   return 0;
}

Ce code parait correct, mais à l'exécution, on constate :

Sélectionnez
1   2   3   4   2

Press ENTER to continue.

Pour vérifier le comportement, je pose un piège qui vérifie la validité de l'index :

  • il doit être >=0 (toujours vrai, vu le type size_t) ;
  • il doit être < n.

Je vais donc ajouter un piège :

Sélectionnez
assert (i < n);

Avant l'accès en lecture au tableau.

Pour être valide, la conception du piège doit se faire sans lire le code à tester, mais en se basant uniquement sur l'interface et le comportement présumé.

Sélectionnez
#include <stdio.h>
#include <assert.h>

static void afficher (int const t[], size_t n)
{
   size_t i;
   for (i = 0; i <= n; i++)
   {
      /* ajout du piège */
      assert (i < n);
      printf ("M", t[i]);
   }
   printf ("\n");
}

int main (void)
{
   int tab[] = {1, 2, 3, 4};

   afficher (tab, sizeof tab / sizeof *tab);
   return 0;
}

Ce qui provoque bien sûr :

Sélectionnez
1   2   3   4Assertion failed: i < n, file main.c, line 10

This application has requested the Runtime to terminate it in an unusual way.
Please contact the application's support team for more information.

Press ENTER to continue.

Ce qui signifie que i a dépassé la valeur maximale qu'autorise le langage C. La cause est évidemment le <= au lieu de < dans l'expression du for(), ce qui entraîne une action corrective immédiate :

Sélectionnez
#include <stdio.h>
#include <assert.h>

static void afficher (int const t[], size_t n)
{
   size_t i;
   /* correction */
   for (i = 0; i < n; i++)
   {
      /* ajout du piège */
      assert (i < n);
      printf ("M", t[i]);
   }
   printf ("\n");
}

int main (void)
{
   int tab[] = {1, 2, 3, 4};

   afficher (tab, sizeof tab / sizeof *tab);
   return 0;
}

L'exécution et, à présent, conforme aux attentes :

Sélectionnez
1   2   3   4

Press ENTER to continue.

XLII. Comportement indéfini

Le langage C est défini par un document unique et reconnu sur le plan international (ISO) par tous les intervenants, que ce soit les développeurs de compilateurs (les 'implémenteurs') les développeurs d'applications (les 'utilisateurs') ou les différents formateurs.

Ce document de référence définit un certain nombre d'éléments (obligations, interdictions).

Les autres éléments sont soit laissés à l'appréciation des implémenteurs (implementation defined ou défini par l'implémentation) qui doivent accompagner leur production (compilateur etc.) d'un document précisant les comportement de tel ou tels éléments, soit non définis du tout. Dans ce dernier cas, le comportement est dit indéfini ou indéterminé. (Undefined Behaviour ou UB)

Quelques exemples :

Sélectionnez
#include <stdio.h>

int main (void)
{
   int i = 0;

   printf ("i = %d\n", i);
   return 0;
}

Ce code est conforme à la spécification du langage, aucune zone n'a été laissée dans l'ombre. Le comportement est déterminé. Il est garanti d'écrire

Sélectionnez
i = 0

Par contre, voici deux cas de comportement indéterminé :

  • Absence de prototype pour printf()
Sélectionnez
int main (void)
{
   int i = 0;

   printf ("i = %d\n", i);
   return 0;
}
  • Lecture d'une valeur non initialisée
Sélectionnez
#include <stdio.h>

int main (void)
{
   int i;

   printf ("i = %d\n", i);
   return 0;
}

Les conséquences d'un UB ne sont pas prévisibles. En effet, ça va du crash au comportement d'apparence conforme. Il est donc impossible de compter sur la simple vérification du comportement pour garantir qu'un code est correct. Il faut avant tout qu'il soit exempt de tout UB.

Le compilateur et ses warnings (ou un outil d'analyse spécialisé comme Lint) peut nous aider à débusquer certains UB. Ici, il est probable qu'une 'utilisation de variable non initialisée' ou qu'un 'appel de fonction sans prototypes' soient détectés (mais ça dépend du compilateur et de ses réglages). Mais il est des cas où le compilateur ne voit rien. Le seul recours est alors l'œil exercé du programmeur expérimenté.

La chasse aux UB est donc ouverte en permanence. C'est la principale source de bugs dans un programme C. Il convient donc, d'une part, de bien connaître le langage et ses limites de définition et, d'autre part, d'être extrêmement vigilant lors de l'écriture et de la relecture du code. Lorsqu'elle est possible, la relecture croisée est une bonne méthode de détection des UB.

Exercice : trouver le UB :

Sélectionnez
#include <stdio.h>

int main (void)
{
   int num = 12;
   char num_text[] = "";

   sprintf (num_text, "%d", num);
   printf ("Voici num_text : %s\n", num_text);
   return 0;
}

XLIII. Les item-lists

XLIII-A. Introduction

Qui n'a jamais été confronté à ce problème :

Comment faire le lien entre des constantes symboliques et leur représentation textuelle ?

Le but des item-lists est de résoudre de façon la plus automatique possible ce genre de problème.

XLIII-B. Mise en oeuvre

Le principe est de séparer les informations de bases constantes (par exemple, la correspondance entre caractère et chaîne morse) dans un fichier indépendant (ni.c, ni.h, on y reviendra), mais que l'on peut inclure (#include) de façon à réaliser une génération automatique de code en fonction de la demande.

Je prends un exemple plus parlant.

Je veux créer une série de constantes qui représentent des fruits

Sélectionnez
enum fruits
{ BANANE, ORANGE, POMME, FRAISE, KIWI };

Ca peut servir à définir une masse de fruit :

Sélectionnez
struct dosage_fruit
{
   enum fruits fruit;
   int masse;
};

puis des recettes de fruits mixés :

Sélectionnez
struct dosage_fruit mix_energie[] =
{
   {BANANE, 100},
   {ORANGE, 150},
   {FRAISE, 80},
};

struct dosage_fruit mix_forme[] =
{
   {BANANE, 50},
   {ORANGE, 50},
   {POMME, 100},
   {KIWI, 50},
   {FRAISE, 50},
};

// etc.

Si on veut afficher la recette, il faut un moyen simple pour convertir la constante fruit en une chaîne imprimable.

On va donc utiliser un tableau de chaînes construit sur le même modèle que le enum (même ordre, c'est primordial, car l'enum sert d'indice au tableau):

Sélectionnez
static char const *chaines_fruits[] =
{
   "banane",
   "orange",
   "pomme",
   "fraise",
   "kiwi",
};

ce qui permet maintenant d'afficher la composition :

Sélectionnez
void afficher_composition (char const *nom, struct dosage_fruit const *a,
                           size_t n)
{
   size_t i;
   printf ("%s\n", nom);
   for (i = 0; i &lt; n; i++)
   {
      struct dosage_fruit const *p = a + i;
      printf ("%d g de %s\n", p->masse, chaines_fruits[p->fruit]);
   }
   printf ("\n");
}

que l'on appelle comme ceci :

Sélectionnez
#define N(a) (sizeof(a) / sizeof *(a))

{
   afficher_composition ("Mix energie", mix_energie, N(mix_energie));
   afficher_composition ("Mix forme", mix_forme, N(mix_forme));

   etc.

Ce qui donne

Sélectionnez
Mix energie
100 g de banane
150 g de orange
80 g de fraise

Mix forme
50 g de banane
50 g de orange
100 g de pomme
50 g de kiwi
50 g de fraise

Press ENTER to continue.

(je laisse au programmeur malin le soin d'écrire «d'orange» au lieu de «de orange»…)

Maintenant, patatras, nouvelle recette à la carte :

Sélectionnez
struct dosage_fruit mix_tropical[] =
{
   {BANANE, 50},
   {ORANGE, 80},
   {MANGUE, 100},
   {ANANAS, 50},
};

Quelles sont les conséquences sur le programme :

Deux modifications :

  • l'enum :
    Sélectionnez
    enum fruits
{ BANANE, ORANGE, POMME, FRAISE, KIWI, MANGUE, ANANAS };
  • la liste des chaînes 'associées' (manuellement, pour le moment) :
Sélectionnez
static char const *chaines_fruits[] =
{
   "banane",
   "orange",
   "pomme",
   "fraise",
   "kiwi",
   "mangue",
   "ananas",
};

Si j'inverse ou que j'en oubli une, c'est la catastrophe. Idem, et c'est beaucoup plus sournois, si j'oublie une ','.

Donc, après quelques sueurs froides, ça marche, et on obtient bien :

Sélectionnez
Mix energie
100 g de banane
150 g de orange
80 g de fraise

Mix forme
50 g de banane
50 g de orange
100 g de pomme
50 g de kiwi
50 g de fraise

Mix tropical
50 g de banane
80 g de orange
100 g de mangue
50 g de ananas

Press ENTER to continue.

Mais c'est déjà beaucoup de stress dans un petit programme comme ça. Dans l'industrie, la moyenne, c'est 1 000 000 de lignes… Pas question de se stresser comme ça si, pour ajouter une valeur dans la liste, il faut modifier dans 10 fichiers différents… (Lesquels ? On n'est pas des robots…)

Par contre, on peut utiliser des techniques de programmations qui font que la maintenance est centralisée en un seul fichier. On le modifie, on recompile tout le projet, et les modifications sont automatiquement reportées dans tout le code.

Pour ça, on va faire travailler la machine à partir d'un fichier unique, pour qu'elle produise ce qu'on veut. C'est toute la puissance qu'offre le préprocesseur bien maîtrisé.

Je vais montrer les différentes étapes pour expliquer le principe, mais dans la pratique, on ne fait que la dernière évidemment.

Dans notre exemple, Les deux éléments à "synchroniser" sont :

Sélectionnez
enum fruits
{ BANANE, ORANGE, POMME, FRAISE, KIWI, MANGUE, ANANAS };

et la liste des chaînes 'associées' (manuellement, pour le moment)

Sélectionnez
static char const *chaines_fruits[] =
{
   "banane",
   "orange",
   "pomme",
   "fraise",
   "kiwi",
   "mangue",
   "ananas",
};

Si on observe bien ces deux éléments, on constate qu'ils se ressemblent. Pour être plus parlant, on va les réorganiser en colonnes :

Sélectionnez
enum fruits
{
   BANANE,
   ORANGE,
   POMME,
   FRAISE,
   KIWI,
   MANGUE,
   ANANAS
};

et la liste des chaînes 'associées' (manuellement, pour le moment)

Sélectionnez
static char const *chaines_fruits[] =
{
   "banane",
   "orange",
   "pomme",
   "fraise",
   "kiwi",
   "mangue",
   "ananas",
};

On voit que le schéma est similaire :

  • une entête ;
  • une liste (chaque élément est terminé par une ,) ;
  • une fin particulière.

On voit que la liste des enum présente une petite irrégularité : le dernier élément n'est pas terminé par une ','. C'est normal (et obligatoire) en C90 (en C99, la virgule est acceptée).

Petite astuce : on ajoute un élément à la liste, qui sert à terminer la liste sans virgule. Il ne fait pas partie de la liste. C'est soit un 'dummy' (inutile), soit, comme ici où les valeurs sont automatiques, une constante qui exprime le nombre d'éléments de la liste, ce qui, à priori, n'est pas complètement inutile…

Modification :

Sélectionnez
enum fruits
{
   BANANE,
   ORANGE,
   POMME,
   FRAISE,
   KIWI,
   MANGUE,
   ANANAS,
   NB_FRUITS
};

Afin de faciliter la maintenance, il faudrait disposer d'une liste 'double' comme ceci :

Sélectionnez
BANANE "banane"
ORANGE "orange"
POMME  "pomme"
FRAISE "fraise"
KIWI   "kiwi"
MANGUE "mangue"
ANANAS "ananas"

Comment faire comprendre ça à un programme C ?

C'est la qu'intervient la puissance du préprocesseur.

Il suffit d'écrire une liste de macros avec deux paramètres. Chaque macro représentant un groupe cohérent d'informations ou 'item' :

Sélectionnez
ITEM (BANANE, "banane")
ITEM (ORANGE, "orange")
ITEM (POMME , "pomme" )
ITEM (FRAISE, "fraise")
ITEM (KIWI  , "kiwi"  )
ITEM (MANGUE, "mangue")
ITEM (ANANAS, "ananas")

Il suffit ensuite d'écrire la définition de ITEM qui correspond à l'usage qu'on en fait :

Sélectionnez
enum fruits
{
#define ITEM(id, chaine)\
   id,

ITEM (BANANE, "banane")
ITEM (ORANGE, "orange")
ITEM (POMME , "pomme" )
ITEM (FRAISE, "fraise")
ITEM (KIWI  , "kiwi"  )
ITEM (MANGUE, "mangue")
ITEM (ANANAS, "ananas")

#undef ITEM

   NB_FRUITS
};

Ce qui va produire automatiquement la bonne liste.

On fait pareil avec l'autre liste :

Sélectionnez
static char const *chaines_fruits[] =
{
#define ITEM(id, chaine)\
   chaine,

ITEM (BANANE, "banane")
ITEM (ORANGE, "orange")
ITEM (POMME , "pomme" )
ITEM (FRAISE, "fraise")
ITEM (KIWI  , "kiwi"  )
ITEM (MANGUE, "mangue")
ITEM (ANANAS, "ananas")

#undef ITEM
};

Le #undef permet le 'recyclage' du nom de la macro qui est invariablement ITEM.

L'étape ultime consiste à inclure la liste à partir d'un fichier extérieur auquel je donne l'extension .itm (par exemple : fruits.itm semble approprié).

Je conseille de placer un commentaire dans ce fichier qui rappelle son nom et le début de la définition de la macro avec la signification des champs.

Sélectionnez
/* fruits.itm

#define ITEM(id, chaine)\

*/
ITEM (BANANE, "banane")
ITEM (ORANGE, "orange")
ITEM (POMME , "pomme" )
ITEM (FRAISE, "fraise")
ITEM (KIWI  , "kiwi"  )
ITEM (MANGUE, "mangue")
ITEM (ANANAS, "ananas")

La maintenance de ce fichier est extrêmement simple. Elle est «visuelle».

Les deux définitions deviennent alors :

Sélectionnez
enum fruits
{
#define ITEM(id, chaine)\
   id,
#include "fruits.itm"
#undef ITEM

   NB_FRUITS
};

et :

Sélectionnez
static char const *chaines_fruits[] =
{
#define ITEM(id, chaine)\
   chaine,
#include "fruits.itm"
#undef ITEM
};

Ce qui allège considérablement le code source et rend la maintenance automatique. (Le C, c'est Bien)

Il peut y avoir des centaines de lignes dans un .itm. Idem pour le nombre de champs. Ici, il y en a deux champs, mais on aurait pu en avoir qu'un seul. En effet, une macro sait transformer un paramètre symbolique (ORANGE) en une chaîne ("ORANGE") avec #. Mais elle ne sait pas modifier la casse des caractères, d'où mon choix de mettre deux champs.

Exemples de fichiers itm que j'utilise

(Simple) : gestion des erreurs

(Complexe) : tables de caractères

Je laisse au lecteur le soin d'écrire l'ensemble de l'exemple et de faire les tests nécessaires. Tous les éléments sont là.


Notes sur le langage C

Table des matières

  • XLIV. Borland C : "floating point formats not linked"
  • XLV. Code standard ? Code portable ? Je suis perdu !
    • XLV-A. "standard" ?
    • XLV-B. "portable" ?
      • XLV-B-1. portabilité absolue
      • XLV-B-2. portabilité relative
    • XLV-C. Bon usage
  • XLVI. Enregistrer une structure
    • XLVI-A. Le format binaire
    • XLVI-B. Le format texte

XLIV. Borland C : "floating point formats not linked"

Un programme généré avec l'IDE Borland C++ 3.1 signale parfois ce message à l'exécution :

Sélectionnez
scanf : floating point formats not linked
Abnormal program termination

Il s'agit en fait d'un bug connu de certains compilateurs Borland. Il se produit lorsqu'on utilise un format 'flottant' avec *printf() ou *scanf(), et qu'on n'utilise pas de fonction de la bibliothèque mathématique.

La parade est simple. Il suffit d'ajouter ces quelques lignes dans le code source contenant le main(), par exemple.

Sélectionnez
#ifdef __BORLANDC__
/* The pesky "floating point formats not linked" killer hack : */
extern unsigned _floatconvert;
#pragma extref _floatconvert
#endif

XLV. Code standard ? Code portable ? Je suis perdu !

On entend parler de code standard, de code portable ? Qu'est-ce que ça signifie ? Cela sert à quoi ?

XLV-A. "standard" ?

Le terme 'standard' est erroné. C'est un anglicisme de 'standard' qui signifie 'norme' (subst.) ou 'normalisé' (adj.).

Le langage C est normalisé. Cela signifie en clair qu'il est défini par un document spécifié et publié sous la responsabilité de l'ISO (International Standard Organisation ou Organisme de normalisation international). Ce document décrit la syntaxe et la sémantique du langage ainsi que l'interface et le comportement des fonctions de la bibliothèque d'exécution (RTL ou Run-Time Library).

Cette spécification s'applique aux compilateurs réputés 'conformes à la norme' et par conséquent aux programmeurs qui les utilise. La spécification définit en gros trois domaines :

  • ce qui est défini par la norme ;
  • ce qui est défini par la cible[1] ;
  • ce qui n'est pas défini.

Ce qui n'est pas défini par la norme peut l'être par une implémentation du C qui comporte des extensions spécifiques à une cible ou à un système. (Mots clés, fonctions, bibliothèques)

Par exemple system() est une fonction normalisée, dont le paramètre est une chaîne de caractères. Cependant, la sémantique du texte porté par cette chaîne de caractères peut varier d'un système à l'autre, voire ne pas être reconnue du tout par le système.

[1] (on dit aussi implémentation (anglicisme), implantation ou plate-forme)

XLV-B. "portable" ?

C'est la capacité qu'a un code source à produire un comportement identique sur différentes plate-formes. On distingue la portabilité absolue (pour n'importe quelle plate-forme) de la portabilité relative (limitée à un certain nombre de plate-formes bien définies).

XLV-B-1. portabilité absolue

C'est lorsqu'un code source ne contient que des éléments normalisés du langage dont la définition et l'utilisation ne dépendent pas de la plate-forme. Certaines pratiques additionnelles peuvent cependant rendre portable du code standard, comme ajouter fflush(stdout) après un printf() qui ne se termine pas par un '\n'.

XLV-B-2. portabilité relative

C'est un code portable 'absolu' auquel s'ajoutent des extensions (généralement, des bibliothèques) tierces conçues pour fonctionner sur un certain nombre de plate-formes bien définies.

La norme POSIX.1 en définit un certain nombre, notamment en matière de gestion des répertoires, processus légers (threads) et réseau (sockets). Mais il existe des initiatives indépendantes comme GTK+ qui définit une interface de programmation graphique pour utilisateur (GUI) commune à Windows, X (Unix/Linux), Apple/Mac et même BeOS.

XLV-C. Bon usage

Le fait de bien connaître les domaines couverts par le langage C constitue une aide considérable pour l'écriture du code. En effet, la portabilité n'est possible que si le code est portable et donc, dans sa grande majorité normalisé, ou tout au moins conforme aux définitions de telle ou telle bibliothèque d'abstraction.

Il est donc impératif de séparer le code normalisé (qui doit représenter la majorité de celui-ci) du code spécifique à telle ou telle plate-forme, et qui n'aurait pas trouvé sa place dans les bibliothèques d'abstraction.

XLVI. Enregistrer une structure

L'enregistrement d'une structure dans un fichier est une opération plus complexe qu'il n'y parait. En effet, le code naif suivant :

Sélectionnez
#include <stdio.h>

struct data
{
   char nom[32];
   int age;
};

int main (void)
{
#define FNAME "data.txt"

   struct data data = { "Emmanuel", 50 };

   FILE *fp = fopen (FNAME, "wb");
   if (fp != NULL)
   {
      fwrite (&data, sizeof data, 1, fp);

      fclose (fp), fp = NULL;
   }
   else
   {
      perror (FNAME);
   }
   return 0;
}

est certes simple et efficace, mais est malheureusement non portable, et ce pour plusieurs raisons :

  • la représentation interne des données en C peut changer d'une implémentation à l'autre, en terme de largeur (nombre de bits), de 'boutisme' (position du byte de poids fort) et de codage (entiers négatifs, nombres réel, jeu de caractères) ;
  • l'alignement requis. En effet, certaines architectures imposent que les éléments de la structure soient alignés sur une adresse multiple de 2, 4 ou autre, ce qui rend impossible de prévoir de façon portable, la signification des bytes dans le fichier.

Pour résoudre ce problème, il y a deux grandes familles de solutions :

  • le format binaire ;
  • le format texte.

XLVI-A. Le format binaire

C'est le plus portable, mais aussi le plus complexe. Il consiste à définir un format de données indépendant de toute implémentation. Il nécessite une conversion à l'écriture (host->file) et une conversion à la lecture (file->host), et ce dans le strict respect du format 'fichier' spécifié. Une méthode simple est TLV (Type, Longueur, Valeur ou Type, Length, Value).

(à venir : exemple de spécification TLV)

Il existe des solutions normalisées comme BER (Basic Encoding Rules) spécifié par les recommandations ITU-T X.209 et X.690 ou XDR (eXternal Data Representation) spécifié par la RFC 1832 plus ou moins basées sur TLV. Ces solutions sont complexes et sont plutôt utilisées avec l'aide d'une bibliothèque tierce comme BER sous Linux.

Dans tous les cas, le fichier est traité en mode binaire ("wb", "rb", "ab"), et les fonctions les plus utilisées sont fgetc(), fputc(), fread() et fwrite().

XLVI-B. Le format texte

Il est un peu moins portable, mais moins complexe. Il consiste à définir un format de données indépendant sous forme de texte. Il nécessite une conversion à l'écriture (host->file) et une conversion à la lecture (file->host), et ce dans le strict respect du format 'fichier' spécifié. Une méthode simple est CSV (Comma Separated Values).

On peut trouver les spécifications sur Wotsit , le site de référence des formats de fichiers.

Le fichier est traité le plus souvent en mode texte ("w", "r", "a"), mais aussi parfois en mode binaire pour régler les problèmes de fins de ligne hétérogènes. (voir plus loin). Les fonctions les plus utilisées sont fgetc(), fputc(), fgets(), fputs() et fprintf().

Les principaux problèmes de portabilité proviennent :

  • du jeu de caractères utilisé. En dehors d'EBCDIC utilisé sur les gros systèmes IBM (Mainframes), c'est généralement ASCII (0-127), mais ça peut ne pas suffire à encoder tous les caractères, notamment les caractères accentués et autres signes mathématiques ou pseudo-graphiques. Or il existe plusieurs façons de coder ces caractères (ASCII étendu, IBM-PC8, UTF-8, Unicode, etc.). Là encore, une définition indépendante peut aider… ;
  • de la façon de coder les fins de ligne (CR, LF, CRLF, etc.).

Certaines corrections doivent donc parfois être effectuées à l'aide de tables de codages et autres astuces algorithmiques.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

L'outil MAKE

Table des matières

  • I. Introduction
  • II. Principes
  • III. Exemple de base
  • IV. Ajout de cibles
  • V. Utilisation des variables
    • V-A. Définition d'une variable
    • V-B. Utilisation d'une variable
    • V-C. Amélioration de l'exemple
  • VI. Variables automatiques

Cet article présente un exemple concret d'utilisation de l'outil MAKE dans le cadre du développement d'un projet C par une approche didactique et pragmatique.

I. Introduction

La production de code (compilation, édition de liens) est assez fastidieuse et difficile à gérer 'à la main' (ligne de commande, scripts...) dès que le projet devient conséquent et évolutif (en fait dès qu'il y a plus d'un fichier en jeu).

Il existe différents outils qui peuvent aider à la gestion des projets. le plus simple et le plus connu est sans aucun doute make (ou une de ses variantes) qui est basé sur une certain nombre de principes et de syntaxes simples.

Dans ce document, je m'efforce de n'utiliser que les caractéristiques les plus courantes qui, bien que l'outil ne soit pas normalisé, devraient se retrouver sur la plupart des versions de make (testé avec le make de Borland et le make de GCC sous DOS/Windows).

Ce document ne prétend en aucun cas être exhaustif. Il s'agit de montrer un exemple concret d'utilisation dans le cadre du développement d'un projet C. L'approche reste didactique et pragmatique.

II. Principes

La structure de base est la suivante

Sélectionnez
<cible> : <dependance[s]>
-TAB-&lt;action[s]>

Le fichier qui contient les commandes de l'outil MAKE s'appelle 'makefile' ou 'Makefile' par défaut. L'invocation de 'make' exécute ce fichier si il se trouve sur le répertoire courant.

Sélectionnez
> make

On peut exécuter un autre fichier de commandes, mais il faut alors utiliser le paramètre -f

Sélectionnez
> make -f projet.mak

La cible sera réalisée en fonction des dépendances et au moyen des actions.

Il y a une tabulation avant chaque action.

La cible par défaut est la première de la liste. On mettra donc la cible finale en premier et les moyens d'y arriver à la suite.

Les caractères situés après un # sont ignorés (commentaires)

L'invocation se fait par :

Sélectionnez
> make

Cible par défaut

Sélectionnez
> make <cible>

Cible particulière

III. Exemple de base

Soit la trilogie classique permettant de tester un petit module

Sélectionnez
/* main.c */
#include "inc/f.h"

int main (void)
{
   f ();
   return 0;
}
Sélectionnez
/* f.c */
#include "inc/f.h"
#include <stdio.h>

void f (void)
{
   puts ("hello world");
}
Sélectionnez
#ifndef H_F
#define H_F

/* f.h */

void f (void);

#endif

Le projet est organisé ainsi :

Sélectionnez
+/<projet>
 |
 +-+/inc
 | |
 | +-f.h
 |
 +-+/src
 | |
 | +-f.c
 |
 +-+/tu
   |
   +-+/src
   |   |
   |   +-main.c
   |
   +-+-/out
   | |
   | +-f.o
   | |
   | +-main.o
   | |
   | +-tu.exe
   |
   +-Makefile

Voici une version basique mais commentée du fichier Makefile servant à réaliser le mini projet :

Sélectionnez
# Makefile

# Cible par defaut (make)
# Cible            : executable
# Dépendances      : les fichiers objets
# Actions          : edition de lien

out/tu.exe: out/main.o out/f.o
   gcc out/main.o out/f.o -o out/tu.exe

# Cible            : fichier objet
# Dépendances      : le fichier source et les fichiers inclus
# Actions          : compilation

out/main.o : src/main.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make src/main.c -o out/main.o

out/f.o    : ../src/f.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make ../src/f.c -o out/f.o

IV. Ajout de cibles

Il est possible d'ajouter des cibles supplémentaires permettant une action particulière. Par exemple, l'effacement de tous les fichiers produits, afin de repartir sur des bases 'saines'.

Une cible sans dépendance sera invoquée systématiquement.

Une cible 'supplémentaire' doit être placée après la cible 'par défaut'.

Le Makefile est donc modifié comme ceci :

Sélectionnez
# Makefile

out/tu.exe: out/main.o out/f.o
   gcc out/main.o out/f.o -o out/tu.exe

out/main.o : src/main.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make src/main.c -o out/main.o

out/f.o    : ../src/f.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make ../src/f.c -o out/f.o

# Cible            : fictive (make clean)
# Dépendances      :
# Actions          : effacement des fichiers produits dans /out

clean:
   del out/*.o
   del out/tu.exe

Pour effacer le projet, il suffit d'invoquer la cible 'clean' :

Sélectionnez
> make clean

'clean' est un nom de cible couramment utilisé mais pas réservé. On aurait pu aussi bien utiliser 'nettoyage'…

V. Utilisation des variables

Le fichier Makefile ci-dessus montre beaucoup de répétitions. Il est possible de simplifier l'écriture et la maintenance de ce fichier en utilisant des 'variables' qui sont en fait plutôt des macros de substitution de texte.

V-A. Définition d'une variable

Sélectionnez
<variable> = <valeur>

V-B. Utilisation d'une variable

Sélectionnez
... $(<variable>) ...

V-C. Amélioration de l'exemple

Les parties répétitives sont 'factorisées' avec des variables. D'autre part, certains éléments pouvant dépendre de la plate-forme de développement font aussi l'objet d'une abstraction (compilateur, paramètres etc.).

Sélectionnez
# <projet>/tu/Makefile

# répertoire projet
DPROJ = /mesdoc~1/emmanuel/site/make

# répertoire de sortie
DOUT = out

# nom de l'exécutable (cible par défaut)
BIN = $(DOUT)/tu.exe

# liste des objets (cibles intermédiaires)
OBJS = $(DOUT)/main.o $(DOUT)/f.o

# chemin des fichiers inclus pour le compilateur
INCLUDES = -I  $(DPROJ)

# liste des dépendances autres que le source
DEPS = ../inc/f.h

# commandes de compilation et d'édition de lien
CC = gcc
LD = gcc

# Commandes simplifiées par l'utilisation des variables.

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $(BIN)

out/main.o : src/main.c  $(DEPS)
   $(CC) -c $(INCLUDES) src/main.c -o out/main.o

out/f.o    : ../src/f.c  $(DEPS)
   $(CC) -c $(INCLUDES) ../src/f.c -o out/f.o

clean:
   del $(OBJS)
   del $(BIN)

Il devient maintenant extrêmement simple d'ajouter des paramètres pour le compilateur :

Sélectionnez
# <projet>/tu/Makefile

DPROJ = /mesdoc~1/emmanuel/site/make
DOUT = out
BIN = $(DOUT)/tu.exe
OBJS = $(DOUT)/main.o $(DOUT)/f.o
INCLUDES = -I  $(DPROJ)
DEPS = ../inc/f.h
CC = gcc
LD = gcc
CFLAGS = -W -Wall -O1 $(INCLUDES)

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $(BIN)

out/main.o : src/main.c $(DEPS)
   $(CC) $(CFLAGS) -c src/main.c -o out/main.o

out/f.o    : ../src/f.c $(DEPS)
   $(CC) $(CFLAGS)  -c ../src/f.c -o out/f.o

clean:
   del $(OBJS)
   del $(BIN)

VI. Variables automatiques

Il existe d'autres variables prédéfinies ou automatiques permettant de simplifier encore le Makefile. Voici une liste indicative :

$@ | Nom de la cible
$^ | Liste des dépendances
$< | Nom de la première dépendance

Attention, le résultat n'est pas garanti avec tous les outils make. Voici les sorties générées par différents outils make à partir de ce fichier de test :

Sélectionnez
# <projet>/Makefile
# Test des variables automatiques

cible.x: dep1.a dep2.b dep3.c
   @echo "@" : $@
   @echo "^" : $^
   @echo "<" : $<

dep1.a :
   @echo $@ > $@

dep2.b :
   @echo $@ > $@

dep3.c :
   @echo $@ > $@

clean:
   del dep*.*
Sélectionnez
GNU Make 3.80
Copyright (C) 2002  Free Software Foundation, Inc.

"@" : cible.x
"^" : dep1.a dep2.b dep3.c
"<" : dep1.a
Sélectionnez
MAKE Version 3.6  Copyright (c) 1992 Borland International

Available memory 131387392 bytes

"@" : cible.x
"^" : $^
"<" : cible.x

Si on utilise GNU make, on peut simplifier le Makefile comme ceci :

Sélectionnez
# <projet>/tu/Makefile

DPROJ = /mesdoc~1/emmanuel/site/make
DOUT = out
BIN = $(DOUT)/tu.exe
OBJS = $(DOUT)/main.o $(DOUT)/f.o
DEPS = ../inc/f.h
INCLUDES = -I  $(DPROJ)
CC = gcc
LD = gcc
CFLAGS = -W -Wall -O1 $(INCLUDES)

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $@

out/main.o : src/main.c $(DEPS)
   $(CC) $(CFLAGS) -c $< -o $@

out/f.o    : ../src/f.c  $(DEPS)
   $(CC) $(CFLAGS)  -c $< -o $@

clean:
   del $(OBJS)
   del $(BIN)

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Notes sur le langage C

Initiation pragmatique au langage C

Table des matières

  • I. Introduction
  • II. Programme minimum
    • II-A. Analyse détaillée
  • III. Un programme qui dit "bonjour"
  • IV. Glossaire
    • IV-A. Bit
    • IV-B. Byte
    • IV-C. Caractère
    • IV-D. Chaîne de caractères
    • IV-E. Comportement indéfini
    • IV-F. Fonction
    • IV-G. Flux
    • IV-H. Paramètre
    • IV-I. Préprocesseur
    • IV-J. printf()
      • IV-J-1. Les principaux formateurs de printf()
      • IV-J-2. Exemples d'utilisation de printf()
    • IV-K. Prototype
    • IV-L. sizeof
    • IV-M. Tableau
    • IV-N. Type

Présentation des bases du langage C par une approche progressive, mais concrète et basée sur l'expérimentation.

Ces bases sont suivies d'un glossaire de termes courants en programmation C.

I. Introduction

Il existe de nombreuses façons d'expliquer les bases d'un langage de programmation. J'ai choisi une approche progressive, mais concrète et basée sur l'expérimentation. Les informations données ne prétendent pas être exhaustives, et font référence à la définition du langage C selon la norme ISO/IEC 9899:1990 (appelée aussi C90).

Pour les détails, il est conseillé de se munir d'un livre de référence comme The C programming language (et son correctif) de Brian Kernighan et Dennis Ritchie ou sa version française : Le langage C. Les solutions aux exercices sont dévoilées ici.

Le langage C est un langage normalisé qui comprend des instructions de préprocesseur, des instructions C, et des symboles dont la sémantique peut varier selon le contexte.

II. Programme minimum

Voici le programme C le plus simple que l'on puisse écrire.

Sélectionnez
int main (void)
{
   return 0;
}

Ce programme est composé de plusieurs mots clés faisant partie du langage C : int, void et return

Il utilise aussi 5 symboles syntaxiques, à savoir (, ), {, } et ;. Enfin, il utilise un mot défini par l'utilisateur : main.

Ce programme ne fait rien de visible. Cependant, il met en œuvre la structure de code de base du langage C, à savoir la fonction.

II-A. Analyse détaillée

Nous allons étudier les mots et le symboles qui constituent le programme dans l'ordre de leur apparition.

int est un type. Ici, il qualifie la valeur retournée par la fonction.

main est un identificateur défini par l'utilisateur. Utilisé dans ce contexte, il désigne le nom de la fonction. Cet identificateur a cependant un sens particulier. Il désigne le point d'entrée du programme. Cela signifie qu'un programme C commencera toujours par un appel 'invisible' à la fonction main(). Cet appel provient de la séquence d'initialisation du programme. Les détails dépendent de l'implémentation, mais, d'une manière simplifiée, voici quel est le déroulement des opérations :

  1. chargement du logiciel ;
  2. exécution du code d'initialisation ("boot") ;
  3. appel de l'application (CALL MAIN) ;
  4. exécution de l'application, jusqu à la fin de main() ;
  5. exécution du code de fin ;
  6. retour au système.

void est un mot clé qui signifie 'rien' ou 'absence de'. Ici, il sert à indiquer que la fonction n'a pas de paramètres.

return est un mot clé qui signifie "quitter la fonction courante". Si la fonction n'a pas de type de retour (type void) on peut utiliser return tout seul suivit d'un point-virgule. Sinon, il doit être suivi d'une valeur et d'un point-virgule. La valeur doit être du type qui a été défini pour la fonction. Cette valeur peut avoir un sens particulier. Il est d'usage de retourner 0 pour signifier que tout va bien (pas d'erreur) et une valeur autre que 0 pour signifier une erreur.

L'usage d'un 'return' sans valeur de retour dans une fonction qui attend une valeur de retour provoque un comportement indéfini.

III. Un programme qui dit "bonjour"

Voici une version un peu plus élaborée, qui se contente d'afficher une simple phrase ("Hello world!") sur la sortie standard (généralement l'écran).

Sélectionnez
#include <stdio.h>

int main (void)
{
   puts ("Hello world!");
   return 0;
}

Dans ce programme, on constate l'ajout du mot clé #include et de son paramètre <stdio.h>, ainsi que la fonction puts(). et de son paramètre "Hello world!".

On remarque que le mot clé #include commence par un '#'. En effet, ce mot clé appartient à la famille des commandes du préprocesseur, dont une caractéristique est justement de commencer par '#'.

#include <stdio.h> signifie que le fichier indiqué en paramètre est inclus dans le code source. Cette opération (ainsi que toutes les opérations du préprocesseur) est effectuée avant la traduction du code.

Cette opération d'inclusion est rendue nécessaire à cause de l'appel à la fonction puts(). En effet, il est recommandé (et parfois obligatoire) de fournir au compilateur un prototype à la fonction utilisée :

Sélectionnez
int puts (char const *);

La fonction puts() appartient à la bibliothèque standard du langage C, qui fait partie intégrante du langage. La définition du prototype de cette fonction se trouvant dans le fichier d'en-tête <stdio.h>, il est donc tout à fait logique d'inclure ce fichier dans le fichier source.

Cette fonction a pour effet d'émettre la chaîne de caractères passée en paramètre vers le flux de sortie standard. Elle ajoute automatiquement un caractère de fin de ligne.

A première vue, le paramètre de cette fonction est une chaîne de caractères, En fait, c'est l'adresse de celle-ci. C'est pourquoi le paramètre de fputs() est défini comme un pointeur sur un type char. En effet, un pointeur est une variable qui peut contenir une adresse.

Le qualificateur const signifie que la fonction accepte l'adresse d'une chaîne non modifiable, car elle s'engage à ne pas modifier la chaîne pointée, ni à passer son adresse à une fonction qui pourrait la modifier.

IV. Glossaire

IV-A. Bit

Le bit (BInary digiT) ou chiffre binaire est l'unité de mesure d'information. Il peut prendre 2 valeurs : 0 ou 1.

IV-B. Byte

Le byte (ou multiplet) est le plus petit objet adressable pour une implémentation donnée. En langage C, il fait au moins huit bits.

IV-C. Caractère

Un caractère est une valeur numérique qui représente un glyphe visible ('A', '5', '*' etc.) ou non (CR, LF etc.). Un caractère est de type int. Cependant, sa valeur tient obligatoirement dans un type char.

Un objet de la taille d'un byte peut recevoir la valeur de n'importe quel caractère.

IV-D. Chaîne de caractères

Une chaîne de caractères est une séquence de caractères encadrée de double quotes.

Sélectionnez
"Hello world!"

La représentation interne d'une chaîne de caractères est spécifiée. C'est un tableau de char terminé par un 0.

Sélectionnez
char s[] = "Hello";

est équivalent à :

Sélectionnez
char s[] = {'H', 'e', 'l', 'l', 'o', 0};

IV-E. Comportement indéfini

Un comportement indéfini (encore appelé Undefined Behaviour ou UB) est un bug grave. Il signifie que le comportement du programme est imprévisible et que tout peut arriver, y compris, et c'est ça qui rend ce bug très dangereux, un comportement d'apparence conforme.

Il n'existe pas de moyen automatique de détecter tous les UB d'un programme. Seul un contrôle visuel fait par une personne chevronnée permet de détecter un tel bug.

IV-F. Fonction

Une fonction est une séquence d'instructions dans un bloc nommé. Une fonction est constituée de la séquence suivante :

  • un type (ou le mot clé void) ;
  • un identificateur (le nom de la fonction, ici main) ;
  • une parenthèse ouvrante ( ;
  • une liste de paramètres ou une liste vide (dans ce cas, on écrit void) ;
  • une parenthèse fermante ) ;
  • une accolade ouvrante { ;
  • une liste d'instructions terminée par un point virgule ;, ou rien ;
  • une accolade fermante }.

L'utilisateur peut créer ses propres fonctions. Une fonction peut appeler une autre fonction. Généralement, une fonction réalisera une opération bien précise. L'organisation hiérarchique des fonctions permet un raffinement en partant du niveau le plus élevé (main()) en en allant au niveau le plus élémentaire (atomique).

On gardera cependant en tête que la multiplication des niveaux introduit une augmentation de la taille du code et du temps de traitement.

Une fonction ne peut être appelée qu'à partir d'une autre fonction.

IV-G. Flux

Un flux est un canal de données orienté byte. Il permet de réaliser des entrées ou des sorties de bytes, soit un par un, soit par bloc.

C'est le mécanisme du langage C qui permet l'interaction avec l'environnement.

Un programme C ouvre 3 flux par défaut :

  • stdin : le flux d'entrée standard ;
  • stdout : le flux de sortie standard ;
  • stderr : le flux de sortie d'erreur.

Sur la plupart des implémentations, ces flux sont connectés à la console.

IV-H. Paramètre

La zone entre parenthèses d'une fonction peut recevoir des paramètres. Un paramètre est défini au minimum par un type et un identificateur.

Exemple :

Sélectionnez
int fonction (int x)
{
}

x est un paramètre de type int. Il est obligatoire, et la valeur passée doit être du même type.

Sélectionnez
...
{
   fonction (123);
}

...
{
   int a = 123;
   fonction (a);
}

Il faut savoir qu'en C, un paramètre ne fait que transmettre une valeur. Modifier la valeur d'un paramètre n'aura jamais d'effet sur la valeur originale. (la fonction printf() permet ici de visualiser la contenu des variables a et x) :

Sélectionnez
#include <stdio.h>

void fonction (int x)
{
   printf ("x = %d (dans la fonction)\n", x); /* x = 123 */

   x = 456;
   printf ("x = %d (dans la fonction)\n", x); /* x = 456 */
}

int main (void)
{
   int a = 123;

   printf ("a = %d\n", a); /* a = 123 */

   fonction (a);
   printf ("a = %d\n", a); /* a = 123 */
   return 0;
}

Après l'appel de la fonction, la valeur de 'a' est inchangée (123).

Sélectionnez
a = 123
x = 123 (dans la fonction)
x = 456 (dans la fonction)
a = 123

Le langage C permet la modification, mais en utilisant des techniques plus avancées.

IV-I. Préprocesseur

Le préprocesseur est un outil qui traduit certaines instructions du code source avant le compilateur. Les instructions du préprocesseur commencent par #. Par exemple #include.

IV-J. printf()

printf() est une fonction standard de la bibliothèque du la bibliothèque du C. Elle est conçue pour émettre des chaînes de caractères formatées vers la sortie standard (stdout). Elle est déclarée dans le fichier d'entête <stdio.h>. Pour l'utiliser, on doit utiliser la directive #include <stdio.h>.

Cette puissante fonction permet de réaliser des affichages formatés. Sa description complète prendrait un chapitre entier. Voici donc quelques éléments de base.

Elle fonctionne selon un principe un peu particulier :

Son premier paramètre est une chaîne de caractères pouvant comporter des séquences spéciales commençant par "%". Le caractère qui suit le "%" a une signification particulière. Certaines sont définies (%, d, i, f, s, c etc.), d'autres non. Le rôle est cette séquence de caractères est d'indiquer à printf() comment il doit interpréter les valeurs passées dans les paramètres suivants. Il doit y avoir correspondance, sinon, le comportement est indéfini.

IV-J-1. Les principaux formateurs de printf()

Formateur de base | Rôle | Type attendu | Types compatibles
% | affiche le glyphe du caractère % | int | short, char
c | affiche le glyphe d'un caractère imprimable | int | short, char
d | affiche la valeur d'un entier en décimal | int | short, char
f | affiche la valeur d'un réel en décimal avec virgule fixe | double | float
s | affiche les glyphes d'une chaîne de caractères | char * |

IV-J-2. Exemples d'utilisation de printf()

Sélectionnez
#include <stdio.h>

int main (void)
{
   printf ("%c\n", 'A');
   printf ("%d\n", 123);
   printf ("%d, '%c'\n", 'A', 'A');

   printf ("Hello world\n");
   printf ("Hello %s\n", "world");
   printf ("%s\n", Hello world);

   return 0;
}

produit :

Sélectionnez
A
123
65, 'A'
Hello world
Hello world
Hello world

Les possibilités complètes de cette puissante fonction sont décrites dans un livre de C comme ceux recommandés au début de ce chapitre.

IV-K. Prototype

Le prototype d'une fonction est une déclaration indiquant :

  • le type du retour (ou void si il n'y en a pas) ;
  • le nom de la fonction ;
  • la liste et le type des paramètres (ou void si il n'y en a pas).

Un prototype peut être intégré à la fonction. Dans ce cas, il est confondu avec la première ligne de la fonction :

Sélectionnez
int function (void)
{
   /* ... */
   return 0;
}

Il peut aussi être détaché (séparé) de la fonction. Dans ce cas, il doit être suivi d'un ';'.

Sélectionnez
int function (void);

IV-L. sizeof

L'opérateur unaire sizeof retourne la taille d'un objet en bytes. Le paramètre de sizeof peut être un objet ou un type. Si c'est un type, celui-ci doit être placé entre parenthèses. Le type retourné par sizeof est size_t (entier non signé). L'expression est une constante évaluée à la compilation.

size_t est un type standard défini dans <stddef.h>.

Sélectionnez
#include <stddef.h>

size_t size_of_an_int = sizeof (int);

double n;

size_t size_of_n = sizeof n;

IV-M. Tableau

Un tableau est une suite d'objets identiques consécutifs.

Sélectionnez
/* Définition d'un tableau de 10 char */
char tab[10];

Il est possible d'initialiser un tableau au moment de sa définition. Les membres non initialisés seront forcés à 0 :

Sélectionnez
/* Définition d'un tableau de 20 entiers
 * de type long avec initialisation a 0
 */
long tab[20] = {0};

/* Définition d'un tableau de 5 flottants
 * de type double avec initialisation partielle
 */
double tab[5] = {12.34, 56.78};

Enfin, il est possible de déterminer la taille d'un tableau par son initialisation. La taille s'ajuste alors en fonction du nombre d'initialiseurs :

Sélectionnez
/* Définition d'un tableau de 6 entiers
 * de type int initialises
 */
int tab[] = {1, 2, 3, 4, 5, 6};

Grâce à l'opérateur sizeof, il est possible de déterminer le nombre d'éléments d'un tableau. Il suffit de diviser la taille du tableau (en bytes) par la taille d'un élément du tableau (en bytes) (par exemple, [0], mais la valeur exacte de l'indice est sans importance).

Sélectionnez
/* Nombre d'éléments de 'tab' */
size_t nb_elem = sizeof tab / sizeof tab[0];

IV-N. Type

Le type est une des propriétés qui caractérise une valeur. Elle permet de préciser quelle est la gamme de valeurs possible. La norme qui définit le langage C précise qu'en fonction du type, ces valeurs doivent couvrir une gamme minimale.

Il y a quatre mots-clés du langage C qui définissent des types :

  • char : petit entier, convient pour les caractères ;
  • int : pour les valeurs numériques entières ;
  • float : nombres à virgule flottante en simple précision ;
  • double : nombres à virgule flottante en double précision.

On peut ajouter des mots-clés particuliers pour modifier la plage de valeurs (short, long) et / ou le fait que la valeur soit signée ou non (signed, unsigned), ce qui décale la plage de valeurs. L'ordre n'a pas d'importance, du moment que ces mots sont placés avant l'identificateur de type.

Voici les types entiers courants avec leurs gammes de valeurs minimales. Les termes entre [] sont facultatifs.

Type | Minimum | Maximum
char | 0 | 127
unsigned char | 0 | 255
signed char | -127 | 127
[signed] short [int] | -32767 | 32767
unsigned short [int] | 0 | 65535
[signed] int | -32767 | 32767
unsigned [int] | 0 | 65535
[signed] long [int] | -2147483647 | 2147483647
unsigned long [int] | 0 | 4294967295

Et uniquement en C99 :

Type | Minimum | Maximum
[signed] long long [int] | -9223372036854775807 | 9223372036854775807
unsigned long long [int] | 0 | 18446744073709551615

On peut mentionner aussi un type défini par l'implémentation, qui est size_t. C'est le type qui est retourné par l'opérateur sizeof. Ce type (entier non signé) est conçu pour contenir des informations du genre "taille d'un objet". Par conséquent, il convient aussi aux indices (croissants) des tableaux ainsi qu'aux nombres d'éléments des tableaux.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Accueil

 Vu dans developpez.com
  1. http://alexandre-laurent.developpez.com/articles/regles-or-programmation
  2. http://chgi.developpez.com/pointeur/
  3. http://emmanuel-delahaye.developpez.com/tutoriels/c/bonnes-pratiques-codage-c/
  4. http://c.developpez.com/cours/historique-langage-c/
  5. http://chgi.developpez.com/pile/
  6. http://franckh.developpez.com/articles/c-ansi/bien-debuter-en-c/
  7. http://nicolasj.developpez.com/articles/erreurs/
  8. http://rperrot.developpez.com/articles/algo/structures/arbres/
  9. http://nicolasj.developpez.com/articles/pieges/
  10. http://franckh.developpez.com/tutoriels/c-ansi/recursivite/
  11. http://melem.developpez.com/tutoriels/langage-c/fichiers/
  12. http://nicolasj.developpez.com/articles/pile/
  13. http://nicolasj.developpez.com/articles/file/
  14. http://cpp.developpez.com/redaction/data/pages/rubrique/cpp/flottant/flottant1/
  15. http://emmanuel-delahaye.developpez.com/tutoriels/c/operateurs-bit-bit-c/
  16. http://rperrot.developpez.com/articles/c/genericite/
  17. http://nicolasj.developpez.com/articles/c99/
  18. http://xrenault.developpez.com/tutoriels/c/scanf/
  19. http://melem.developpez.com/tutoriels/langage-c/compilation-separee/


Les règles d'or de la programmation

Cet article recueille des conseils et les bonnes pratiques à adopter en programmation.

I. Introduction

Ce document n'a pas pour but d'être une bible de la meilleure méthode de programmation. Il m'a été inspiré non seulement par les différents stagiaires dont je me suis occupé et par les morceaux de code que j'ai pu voir, postés ici et là, mais aussi par ma propre expérience professionnelle. La question que je me suis souvent posée étant la suivante : comment faire pour écrire le meilleur programme possible ?

Au bout de quelque temps, je me suis aperçu que je ne pourrai pas atteindre ce but : en effet, répondre à cette question implique d'atteindre un absolu, sans tenir aucun compte de notre environnement de travail : comment, par exemple, écrire le meilleur programme possible alors que notre responsable ou notre client vient nous parler tous les jours de délai de livraison ?

J'ai donc modifié cette question en une autre : comment faire pour devenir un meilleur développeur ? Nous sommes tous perfectibles et donc, nous devons nous améliorer.

Quel que soit votre environnement de développement, langage, société dans laquelle vous travaillez, votre programme et vous-même devez répondre aux règles décrites dans cet article.

II. Efficacité

Vous, comme votre programme, devez être efficace. C'est-à-dire arriver à un maximum de résultat pour un minimum d'effort. Pour ceci, non seulement il existe des méthodes de programmation dont vous pouvez vous inspirer mais vous devez toujours garder en tête, à la fois le souci de votre propre efficacité mais aussi celui de votre programme.

Votre propre efficacité commence dès la conception en répondant aux besoins de l'utilisateur et uniquement aux besoins de l'utilisateur : il est inutile de développer une fonction compliquée quand il en veut une simple ou même de développer une fonctionnalité non demandée mais qui vous paraîtrait indispensable. Si vous avez des doutes, vous pouvez toujours lui poser des questions complémentaires.

Ne confondez pas l'efficacité de votre programme avec sa rapidité : il est inutile de mettre en œuvre un algorithme plus compliqué si vous passez plus de temps à l'écrire. Si jamais lors de vos tests vous vous rendez compte qu'il y a un problème de temps de réponse, il sera toujours temps d'envisager une autre manière de faire. En résumé, vous devez être rapide à écrire votre programme quitte à réutiliser des morceaux de code que vous avez utilisés dans un autre programme.

Il s'agit d'écrire le moins de code possible. Votre programme devra être clair et sans redondance de code.

Parmi les méthodes de programmation que vous pouvez utiliser, une d'entre elles me semble obligatoire : il s'agit de découper les tâches à réaliser en tâches de plus en plus petites. Par exemple, pour construire une maison, vous aurez une tâche qui va s'appeler contruire_maison et pour construire cette maison vous en aurez d'autres qui pourraient s'appeler construire_fondations, construire_murs, construire_toiture et enfin remplir_maison. Il devient évident que l'on va continuer à découper chacune de ces quatre tâches par d'autres qui le seront encore à leur tour. Une telle méthode présente l'avantage de ne rien oublier et de laisser un programme clair, sous réserve que vous le commentiez un minimum et que vous utilisiez des noms convenables : clair, précis et concis.

Une autre méthode, importante également surtout si vous avez une grosse application à développer est de la développer par module. Vous pourrez alors sans doute livrer au client un premier module dont il pourra se servir pendant que vous écrirez les modules suivants. Par exemple, il pourra commencer à remplir la base de données pendant que vous travaillerez sur la suite. Ce qui vous permettra d'avoir d'un jeu d'essais plus proche du réel. De plus, un module bien conçu pourra être réutilisé dans d'autres projets.

Pour gagner en efficacité, il devient utile de copier-coller du code. En effet, il se peut que certaines fonctionnalités aient déjà été développées. Si votre code est bien conçu, il suffira juste de copier la fonction ou la classe pour l'intégrer dans votre nouveau projet.
Si c'est un code venant d'Internet, il est extrêmement important de le comprendre avant de l'intégrer. Si vous sautez cette étape, vous allez entraîner des failles de sécurité et de fiabilité dans l'application.

Le copier-coller peut être une bonne pratique pour ne pas réécrire un code. Toutefois, si vous effectuez un copier-coller de votre code dans votre propre projet (création d'un duplicata de code), ceci est une mauvaise pratique. En effet, si ce code contient un bogue, vous allez possiblement oublier de le corriger aux deux endroits. De plus, lors de l'utilisation de la copie, il vous faudra peut-être modifier le nom des variables et certaines peuvent être oubliées, créant ainsi de nouveaux bogues.
Ainsi, à la place d'une copie, il est préférable de créer une fonction et de l'appeler deux fois.

III. Fiabilité

Votre programme doit être fiable, c'est-à-dire contenir un minimum d'erreurs de programmation. Cela signifie que vous devez tester votre programme. Vérifiez que le programme fait bien ce que vous voulez qu'il fasse et ce, pas seulement avec un seul test. En général, le développeur a tendance à tester les valeurs qui l'arrangent, il vous faut donc un jeu d'essais complet à lancer à chaque modification que vous apportez à votre application. De plus, ne laissez pas le programme se planter lamentablement : gérez les erreurs proprement. Si une fonction retourne un code d'erreur ou une exception (pour les langages les supportant), vous devez le prendre en compte. Une fois que vous vous apercevez d'un quelconque problème, réfléchissez à ce qui doit être fait selon la gravité de l'erreur. Si une erreur fatale se produit, sauvegardez les documents de l'utilisateur. Si c'est une erreur bénigne, ne bloquez pas l'application, gérez-la de la manière la plus simple (par exemple, en ignorant les résultats produits lorsqu'une erreur est découverte ou en indiquant un petit message à l'utilisateur) et continuez le travail.

Il vous faut tester votre programme non seulement avec des tests unitaires mais aussi avec des tests d'intégration. Les tests unitaires sont à effectuer à chaque étape importante de l'écriture ou de la modification de votre programme ou module. Les tests d'intégration sont à effectuer à la fin de l'écriture ou de la modification d'un module afin de contrôler qu'il s'intègre bien avec les autres. Le must est d'automatiser ces tests dans la chaîne de développement. Par exemple, on peut mettre en place une machine qui exécutera les tests toutes les nuits en prenant la dernière version du code (grâce à un logiciel de contrôle de version). Comme cela, en arrivant le matin, les développeurs sauront si un problème est apparu et seront alertés au plus tôt. Ainsi, chaque bogue doit avoir son test, afin qu'il ne réapparaisse plus jamais.

Lors de vos tests unitaires vous serez amenés à choisir les valeurs à tester. Il faut toujours en tester plusieurs et les choisir à bon escient : au moins la valeur minimale, la valeur maximale, une valeur hors norme par raison d'anormalité et quelques valeurs convenables qui, dans l'idéal, devront être choisies parmi des valeurs réelles, c'est-à-dire des valeurs en vigueur chez vos utilisateurs.

IV. Robustesse

Votre programme doit être robuste, c'est-à-dire résister aux erreurs de l'utilisateur. Vous devez donc tester chaque zone de saisie au mieux. Par exemple, ne le laissez pas utiliser le 22e élément d'un tableau qui n'en comporte que 20. Dites-le-lui avant. Obligez-le à saisir les zones qui sont réellement obligatoires. D'une manière générale, la programmation défensive est une technique qui donne de bons résultats, car elle vous force à supposer que votre utilisateur va commettre des erreurs. En ce qui concerne vos propres erreurs, des tests unitaires et la programmation par contrat doivent donner des résultats suffisants.

V. Interface

Vous devrez en outre faire un effort particulier au moment de créer votre interface, que ce soit en n-tiers ou non. Votre programme doit faciliter la vie de l'utilisateur, il ne doit pas lui créer des problèmes supplémentaires. Chacune des actions que l'utilisateur doit réaliser doit l'être de manière claire et précise pour lui. S'il se pose trop de questions, il risque de se tromper. Par exemple, s'il met trop de temps pour créer une fiche client, il risque de garder ses dossiers alors qu'un des buts de votre application était peut-être de supprimer ces dossiers pour les remplacer par l'écran. Une autre chose qui va considérablement influer sur le ressenti de l'utilisateur : la documentation que vous lui fournirez. Celle-ci doit être claire et agréable à lire. Elle doit inclure toutes les fonctions de votre application et contenir également tous les messages que votre programme est susceptible d'afficher avec leur signification et éventuellement une codification, car lorsqu'il va avoir un message d'erreur il doit pouvoir se débrouiller seul et ne vous appeler qu'en dernier recours.

VI. Portabilité

Rendre son programme portable est un choix. Toutefois, personne n'est à l'abri du changement, donc quelle que soit la bibliothèque que vous utilisez (multiplate-forme ou pas) n'hésitez pas à prévoir un code architecturé pour faciliter le changement de bibliothèque, si besoin.

Si votre programme doit être portable, n'en faites pas trop. Par exemple, si vous écrivez un site web, il faudra que votre utilisateur puisse l'utiliser avec n'importe quel navigateur et pas seulement avec celui sous lequel vous l'avez écrit et testé. Idem en ce qui concerne les versions Windows : si vous développez sous XP, faites attention que votre programme puisse être utilisé sous les versions suivantes plus exigeantes en ce qui concerne la sécurité. Par contre, il sera sans doute inutile d'assurer une portabilité de Windows vers Linux ou vers les portables et inversement. À quoi donc pourrait servir une géolocalisation de l'utilisateur sur un poste fixe ?

VII. Maintenabilité

Votre programme doit être maintenable, c'est-à-dire non seulement pouvoir être modifié facilement par vous-même dans six mois mais également par quelqu'un d'autre.

Pour ceci, une des toutes premières choses à faire est de mettre en place tout un système de documentation. Documenter implique de commenter vos choix dès la conception dans le dossier d'analyse pour les expliquer jusque dans les moindres fonctions afin que l'on puisse savoir à quoi elles servent. Mais mettez des commentaires utiles : il est inutile d'écrire « incrémentation de i » à côté d'un i++. Utiliser des outils tels que Javadoc ou Doxygen peut vous aider à construire la documentation de votre programme. Il restera à faire celle allant avec le MCD.

Maintenable, cela veut dire aussi : facilement lisible. Vous pouvez utiliser des techniques de nommage comme Camel Case. Cette technique fait que vos variables ressemblent à un dos de chameau du fait de l'alternance de majuscules et de minuscules, comme pour « maSuperbeVariable », d'où son nom. Vous pouvez aussi ajouter un préfixe pour savoir si la variable est une variable membre de la classe (m_) ou encore, un pointeur (p). Dans tous les cas, il faut choisir un nom pour vos variables, fonction, classes, explicite tout en étant court. Vous devez aussi indenter votre programme surtout quand des IDE comme NetBeans le font pour vous.

Une autre manière d'améliorer sa maintenabilité est de pratiquer la refactoristion (refactoring), c'est-à-dire de réécrire le code une fois qu'il respecte au mieux les principes énoncés précédemment. Souvent, un code est écrit dans la rapidité afin de répondre à un besoin et à un délai et celui-ci contiendra donc très certainement des erreurs (il se peut qu'il fonctionne, mais qu'il casse le design de l'application, ou qu'il ne vérifie pas les erreurs retournées par les fonctions, par exemple). La refactorisation permet de réécrire ce ou ces morceaux de code afin de fortifier l'architecture de l'application. Ainsi, vous gagnerez en lisibilité, en fiabilité et en maintenabilité. De plus, comme la refactorisation peut être une opération apportant de nouveaux bogues (car modification du code existant), les tests unitaires automatisés permettront de repérer immédiatement les nouveaux problèmes et de les corriger immédiatement. Ainsi, vous pouvez faire progresser votre application tout en vous assurant de ne pas apporter de problèmes.

Il ne faut pas confondre factorisation et refactorisation. Le premier décrit le fait de créer une fonction afin d'éviter la répétition d'un même code. Le second énonce tout le processus mis en place afin d'améliorer le code, le rendre robuste, lisible, réutilisable, … Il permet entre autres de corriger des erreurs insérées lors de l'écriture de code et de corriger des failles que l'on aurait oubliées. Lors d'une refactorisation, le comportement du programme n'est pas modifié. De plus, lors d'une refactorisation, le code peut être factorisé.

VIII. Travail en commun, versionning et sauvegardes

Vous allez, peut-être, travailler en commun : vous avez besoin de vous assurer que vous n'êtes pas plusieurs à modifier le même code source en même temps.

Vous avez besoin de sauvegarder vos programmes : Il faudra garder plusieurs sauvegardes, au moins une pour les deux dernières versions de votre application voire plus si vous aviez besoin de revenir plus loin encore. En plus d'une sauvegarde à chaque version qui fonctionne, une sauvegarde par jour ou demi-journée est nécessaire au moment où vous développez.

Les outils de versionning sont faits pour ça. Voyez par exemple Subversion, Mercurial, ou Git qui sont des logiciels vous permettant de sauvegarder votre travail sur un serveur distant, mais aussi de garder les informations liées à chaque modification du code et ainsi de pouvoir travailler à plusieurs sur le même projet.

La perte de code n'est pas une fiction. Cela arrive à beaucoup de débutants. C'est pourquoi, même pour un projet personnel, il est conseillé d'utiliser un outil de versionning. Toute entreprise en utilise et maintenant, les universités en installent dans leur parc informatique.

IX. Conclusion

Au cours de cet article, nous avons vu les différentes règles de base que tout programmeur doit suivre dans la réalisation de ses projets. En effet, le tout n'est pas de faire une application qui fonctionne, mais aussi faire en sorte que celle-ci puisse être modifiée aisément par vous ou votre successeur tout en étant le plus efficace. C'est en suivant ces règles simples que vous deviendrez un meilleur programmeur.

Toutefois, l'article s'est limité à parler de la programmation de façon générique. Chaque langage possédera des règles spécifiques qui lui seront propres et qui ne pouvaient donc pas être regroupées ici. Sachez que vous apprendrez ces règles en programmant.


Les pointeurs du C et du C++.

Les pointeurs vous harcèlent, vous hantent, vous terrorisent, ce document est fait pour vous. Il a pour but d'aider les débutants en C/C++ à aborder les pointeurs avec le moins d'appréhension possible. Avant de rentrer dans le vif du sujet, nous ferons un rappel sur la définition d’une variable.

Rappel :

Une variable est destinée à contenir une valeur du type avec lequel elle est déclarée. Physiquement cette valeur se situe en mémoire.
Prenons comme exemple un entier nommé x :

int x;    // Réserve un emplacement pour un entier en mémoire.
x = 10;   // Ecrit la valeur 10 dans l'emplacement réservé.

Décomposons notre exemple :
Nous allons représenter la mémoire de l'ordinateur par des cases numérotées en ordre croissant. Le numero de la case étant ce qu'on appellera son adresse. On considérera qu'une variable utilise une case, même si dans la réalité elles ne prennent pas la même quantité de mémoire selon leur type.
Voyons-en une partie sous forme d'un schéma :

Quand j'écris : int x;

Je réserve une case pour la variable x dans la mémoire, case numéro 62 dans le cas du schéma.

Quand j'écris : x=10;

J'écris la valeur 10 dans l'emplacement réservé pour x. On dit que x a pour valeur 10. Cette valeur est située physiquement à l'emplacement &x (adresse de x) dans la mémoire (62 dans le contexte du schéma).
Pour obtenir l'adresse d'une variable on fait précéder son nom avec l'opérateur '&' (adresse de) :

printf("%p",&x);

Ce qui dans le cas du schéma ci-dessus, afficherait 62.

Le pointeur :

Un pointeur est aussi une variable, il est destiné à contenir une adresse mémoire, c'est à dire une valeur identifiant un emplacement en mémoire. Pour différencier un pointeur d'une variable ordinaire, on fait précéder son nom du signe '*' lors de sa déclaration.
Poursuivons notre exemple :

int *px;   // Réserve un emplacement pour stocker une adresse mémoire.
px = &x;   // Ecrit l'adresse de x dans cet emplacement.

Décomposons :

Quand j'écris : int *px;

Je réserve un emplacement en mémoire pour le pointeur px (case numéro 96 dans le cas du schéma). Jusqu'a ici il n'y a donc pas de différence avec une variable ordinaire.

Quand j'écris : px = &x;

J'écris l'adresse de x à l'emplacement réservé pour le pointeur px. Je rappelle qu'un pointeur est destiné à mémoriser une adresse mémoire.

A l’emplacement réservé pour le pointeur px, nous avons maintenant l’adresse de x. Ce pointeur ayant comme valeur cette adresse, nous pouvons donc utiliser ce pointeur pour aller chercher (lire ou écrire) la valeur de x. Pour cela on fait précéder le nom du pointeur de l’opérateur de déréférencement '*'.
Donc l'instruction suivante :

printf("%d",*px);

Affiche la valeur de x par pointeur déréférencé (10 dans le cas du schéma).

On peut donc de la même façon modifier la valeur de x :

*px = 20; // Maintenant x est égal à 20.

On se rend vite compte qu'un pointeur doit être initialisé avec une adresse valide, c'est à dire qui a été réservée en mémoire (allouée) par le programme pour être utilisé.
Imaginez-vous l'instruction précédente, si nous n'avions pas initialisé le pointeur avec l'adresse de x, l'écriture se ferait en un lieu indéterminé de la mémoire.

Dans l'exemple précédent vous avez dû remarquer que nous avons donné un type au pointeur (int *), même si dans un système donné un pointeur a toujours la même taille (4 octets pour un système à adressage sur 32 bits), le langage impose de leur donner un type. Si vous ne savez pas à l'avance sur quel type de données il va pointer vous pouvez lui donner le type pointeur void.

Il est d'usage de préfixer le nom des variables de type pointeur de la lettre "p" ceci pour une meilleure lisibilité du code.

Pointeurs et tableaux :

L'utilisation du pointeur de l'exemple précédent n'a qu'un intérêt pédagogique. Mais en C/C++, il y a des cas ou on ne peut pas se passer de leur utilisation. Prenons le cas des tableaux : Le nom d'un tableau sans décoration retourne l'adresse du premier élément du tableau. Ceci fait que l'on peut l'utiliser de la même manière qu'un pointeur tout en gardant à l'esprit que ce n'est pas un pointeur pour autant.
Soit le tableau Tab suivant :

       int Tab[10]={5,8,4,3,9,6,5,4,3,8};

L'instruction suivante affiche bien la valeur du premier élément du tableau par pointeur déréférencé.

       printf("%d",*Tab);

Ceci nous montre que le nom d'un tableau peut trés bien s'utiliser comme un pointeur sur son premier élément. On peut alors tout à fait déclarer un pointeur et l'initialiser avec le nom du tableau. A condition bien sûr qu'il soit du même type, pointeur sur des entiers dans notre cas :

      int *pTab;
      pTab = Tab;

On peut donc se servir de ce pointeur comme s'il était un tableau et des opérateurs crochets [] pour accéder à ses éléments :

      printf("%d",pTab[0]);	// Affiche 5.

Mais ce n'est pas un autre tableau c'est le même que Tab, il référence le même emplacement en mémoire :

      pTab[0]++;
      printf("%d",Tab[0]);	// Affiche 6.

Quand j'incrémente le premier élément du tableau en utilisant le pointeur c'est bien le premier élément du tableau d'origine qui est incrémenté.

Que ce passe t'il si j'écris :

      printf("%d",Tab[10]);

J'ai l'affichage d'une valeur ne faisant pas partie de mon tableau. Ceci est dû au fait que je vais lire une valeur en dehors des limites du tableau sans que le système signale une erreur. L'opérateur crochet n'étant qu'une écriture simplifiée du pointeur déréférencé, ceci :

      printf("%d",Tab[10]);

est équivalent à cela :

      printf("%d",*(Tab+10));

Un pointeur et donc un tableau peuvent facilement accéder à n'importe quel lieu en mémoire sans la moindre alerte, jusqu'au plantage de l'application.
Cette dernière écriture ne vous est peut-être pas familière pour accéder au contenu d'un tableau. Elle nous amènera donc à parler de l'arithmétique des pointeurs.

Arithmétique des pointeurs.

Reprenons le cas de notre tableau Tab. De même que pTab, puisqu'initialisé avec Tab, C'est un pointeur sur son premier élément. Si j'incrémente le pointeur pTab il ne contiendra pas l'adresse du premier élément du tableau + 1, mais l'adresse de l'élément suivant. La valeur de l'adresse qu'il contient sera donc incrémentée de la taille du type qu'il référence. Ceci est l'une des raisons pour lesquelles il faut donner un type à un pointeur.
Donc si j'écris :

       printf("%d",*(Tab+1));	// Affiche 8.

J'ai bien l'affichage du deuxième élément du tableau.
De même si j'écris :

       pTab++;
       printf("%d\n",pTab[0]);

J'ai aussi l'affichage du deuxième élément du tableau d'origine, puisque mon pointeur ayant été incrémenté d'une unité, il contient maintenant l'adresse du deuxième élément du tableau.
On peut de la même façon décrémenter un pointeur, lui ajouter ou lui soustraire une valeur numérique entière (attention toutefois à ne pas sortir des zones de mémoire allouées). Donc si j'écris :

       pTab = Tab+4;
       printf("%d\n",pTab[0]);	// Affiche 9.

J'ai l'affichage du 5ème élément du tableau car pTab est maintenant un pointeur sur son 5ème élément.

Pointeurs et allocation dynamique de la mémoire.

Une utilisation très courante des pointeurs est l'allocation dynamique de la mémoire. Quand on fait une allocation dynamique de mémoire on obtient en retour un pointeur sur la zone mémoire allouée.
Exemple en C :

        int *p = malloc(10*sizeof(int));
        if(p)
          {
            p[0] = 5;
            printf("%d",p[0]);
            free(p);
          }

Dans cet exemple l'instruction malloc nous retourne un pointeur sur une zone mémoire de la taille de 10 entiers (en C++, le malloc retournant un pointeur de type void (void*), nous devons le transtyper pour l'utiliser avec des entiers). Nous pouvons maintenant utiliser notre pointeur comme s'il était un tableau de 10 entiers (malloc retourne la valeur NULL si l'allocation à échouée). Les emplacements mémoire alloués dynamiquement doivent être libérés explicitement, à l'aide de l'instruction free dans le cas d'une allocation par malloc.

En C++ le principe est identique sauf que l'on préferera l'utilisation de l'opérateur new pour l'allocation et delete (delete[] dans le cas d'un tableau) pour la libération.

        int *p = new int[10];
        p[0] = 5;
        std::cout << p[0];
        delete[] p;

Attention toutefois à ne pas modifier ce pointeur car il est nécessaire pour la libération de la mémoire. Vous pouvez en ce cas le déclarer comme pointeur constant pour éviter de le modifier:

      int *const p = new int[10];

Pointeurs comme paramètres de fonctions.

Une autre utilité des pointeurs est de permettre à des fonctions d'accéder aux données elles même et non à des copies. Prenons pour exemple une fonction qui échange la valeur de deux entiers :

void exchange(int *x, int *y)
{
 int tmp;
 tmp = *x;
 *x = *y;
 *y = tmp;
}

Pour que la fonction puisse affecter leurs nouvelles valeurs à chaque variable elle doit y avoir accès, le passage des variables par pointeur permet donc à la fonction d'accéder aux variables par pointeur déréférencé.
Voyons l'exemple d'utilisation de la fonction :

    int a;
    int b;
    a = 5;
    b = 10;
    printf("a = %d\nb = %d\n",a,b);
    exchange(&a, &b);
    printf("a = %d\nb = %d\n",a,b);

On passe donc l'adresse des variables a et b comme paramètres puisque la fonction attend des pointeurs comme paramètres. Pour le pointeur les paramètres sont en fait passés par valeur, puisque l'adresse de la variable est bien copiée dans le pointeur créé afin d'être utilisé par la fonction (à savoir qu'en C++ on aurait pu utiliser les références pour cet exemple).

Le passage de paramètres sous forme de pointeur est aussi utilisé pour passer un tableau en tant que paramètre de fonction, c'est d'ailleurs la seule solution possible dans le cas d'un tableau. La fonction reçoit donc un pointeur sur le premier élément du tableau, mais la fonction ne devant pas accéder en dehors des limites du tableau, elle doit pouvoir en contrôler le traitement dans ce cas. Pour une chaîne de caractères, on peut tester la présence du caractère de fin de chaîne '\0', mais dans la majorité des autres cas il faudra fournir à la fonction la taille du tableau. Prenons comme exemple une fonction qui retourne la plus grande valeur d'un tableau d'entier :

int max(int *tab, int n)
{
    int x;
    int nmax;
    nmax = 0;
    for(x=0; x<n; x++)
                 if(tab[x]>nmax) nmax=tab[x];
    return nmax;
}

Nous lui fournissons donc l'adresse du premier élément du tableau comme premier paramètre et la taille du tableau en second paramètre. Dans l'implémentation de la fonction nous utilisons le pointeur comme s'il était un tableau d'entier (utilisation de l'opérateur crochet "[]" pour accéder à ses éléments). Voici un code d'utilisation de la fonction max :

int Tab[] = {12,5,16,7,3,11,14,6,11,4};
printf("%d",max(Tab,10));

On aurait pu écrire l'entête de la fonction comme ceci :

int max(int tab[], int n)

ou

int max(int tab[10], int n)

Ces écritures étant équivalentes à la première, la fonction recevra dans tous les cas une copie du pointeur sur le tableau.

Les pointeurs de fonction :

La valeur renvoyée par le nom (seul) d'une fonction étant l'adresse de son code en mémoire, nous pouvons l'affecter à un pointeur.
Dans l'exemple ci-dessous ou nous créons un pointeur de fonction sur la fonction "max" vu précédemment.

     int(*pmax)(int*, int);
     pmax = max;

     printf("%d",pmax(Tab,10));

Le pointeur doit être déclaré avec la signature de la fonction, c'est-à-dire dans le cas de notre exemple le pointeur pmax est un pointeur sur des fonctions recevant en paramètre un pointeur sur un entier puis un entier et retournant un entier. Au pointeur ainsi déclaré doit ensuite être affectée l'adresse d'une fonction (max dans notre exemple) ayant la même signature. Le pointeur s'utilise alors avec la même syntaxe que la fonction.

Ce document n'ayant pour but que d'aborder les pointeurs, j'espère tout de même qu'il vous aura un peu aidé à leurs compréhensions. Il faut bien sûr ne les utiliser que quand cela est nécessaire. En C++ d'autre type de données permettent d'en faire abstraction. Signalons aussi que l'API Windows utilise abondamment les pointeurs comme paramètres de fonctions. Les pointeurs de fonctions y sont utilisés pour aller chercher des fonctions incluses dans les librairies dynamiques (dll).

Pour continuer vous pouvez aller consulter le tutoriel sur les listes chaînées où les pointeurs sont abondamment utilisés.


Bonnes pratiques de codage en langage C

Table des matières

  • I. Présentation du code source
  • II. Conventions de nommage
  • III. Organisation du code source
  • IV. Comment bien organiser son développement
  • V. Comment construire sa bibliothèque
  • VI. Comment bien configurer son compilateur
  • VII. Ressources

Cet article est une synthèse de plusieurs notes autour des bonnes pratiques de codage à adopter en langage C.

I. Présentation du code source

Il est extrêmement facile d'écrire du code illisible en C, que ce soit par une mauvaise présentation, ou par un usage abusif des formes contractées.

Il est, par contre très utile, de présenter du code clair. Cela aide à la compréhension, à la relecture, au contrôle visuel, à la maintenance.

I-A. Jeu de caractères

Le jeu de caractères normalisé par le langage C est

Sélectionnez
  A B C D E F G H I J K L M
  N O P Q R S T U V W X Y Z
  a b c d e f g h i j k l m
  n o p q r s t u v w x y z
  0 1 2 3 4 5 6 7 8 9
  ! " # % & ' ( ) * + , - . / :
  ; < = > ? [ \ ] ^ _ { | } ~

plus l'espace, et les caractères de commande représentants :

  • le saut de ligne ;
  • le retour chariot ;
  • la tabulation horizontale ;
  • la tabulation verticale ;
  • le saut de page.

L'utilisation de caractères supplémentaires (accentués, par exemple) invoque un comportement défini par l'implémentation.

Dans la pratique, la plupart des compilateurs acceptent les extensions courantes comme IBM PC8 et ISO 8859-1 (aussi appelées respectivement OEM et ANSI dans le monde MS-DOS/Windows).

Personnellement, j'évite d'utiliser les caractères autres que les caractères standards dans un code source, y compris dans les commentaires.

I-B. Indentation

I-B-1. Comment bien présenter le code source

Il existe de nombreuses façons de présenter le code C. Certaines sont plus prisées que d'autres, et je n'entrerai pas dans ce débat. Par contre, il est important, une fois qu'on a décidé de ce qui était "bien", de s'y tenir.

Tout le code présenté sur ce site observe des règles homogènes de présentation. Un moyen simple d'y parvenir est d'utiliser un outil unique pour la mise en page (indentation), tel que GNU Indent. Cet outil dispose de nombreux paramètres que personnellement j'ai fixés une fois pour toute, et qui confèrent à mes codes sources une certaine unité.

Sélectionnez
indent -bli0 -npsl -i3 -ts0 -sob

I-B-2. Tabulations contre espaces

Il est fortement recommandé d'utiliser un éditeur qui permet de remplacer les tabulations par des espaces (l'idéal étant que le nombre soit programmable). En effet, l'effet d'une tabulation n'est pas uniforme, alors que celui d'un espace l'est certainement d'avantage (à part en HTML, mais il y a des contournements possibles).

I-C. Commentaires

I-C-1. Comment bien gérer les commentaires

Le langage C, dans sa version normalisée ISO-1990, ne supporte que les commentaires de type /* comment */. L'imbrication des commentaires n'est pas autorisée.

Il est préférable d'éviter de placer des commentaires en bout de ligne. En effet, certains périphériques ou applications limitent la largeur visible du texte à moins de 80 colonnes. Ce serait dommage de se priver de commentaires qui peuvent être utiles. Il est donc recommandé de placer ses commentaires au-dessus de la portion de code à commenter.

Sélectionnez
/* compteur */
int cpt ;

Si un commentaire doit utiliser plusieurs lignes, il est recommandé d'utiliser la présentation 'ouverte', qui facilite la maintenance, tout en préservant la lisibilité. Exemple de forme minimale :

Sélectionnez
/* Ce compteur doit être initialise
   avant utilisation */
int cpt ;

S'il faut isoler provisoirement une portion de code, le mieux est de ne pas utiliser les commentaires (il pourrait y avoir des commentaires imbriqués), mais plutôt les directives du préprocesseur : #ifdef .. #endif ou #if .. #endif

Sélectionnez
#if 0
  /* Compteur */
  int cpt ;
#endif

I-C-2. Comment bien commenter

Le moins possible !

Le principe est de ne commenter que ce qui apporte un supplément d'information. Il est d'usage d'utiliser en priorité l'auto-documentation, c'est à dire un choix judicieux des identificateurs qui fait que le code se lit 'comme un livre'…

I-D. Éditeur de texte

I-D-1. Bien choisir son éditeur de texte

L'outil avec lequel un programmeur passe probablement le plus de temps est son éditeur de texte. Il entre dans une part importante de la productivité du programmeur et de la présentation du code.

Je n'entrerai évidemment pas dans le débat futile Vi/Emacs, car je mets tout le monde d'accord avec UltraEdit32 ! En fait peu importe, pourvu qu'il dispose au moins des fonctions suivantes :

  • police à chasse fixe ;
  • coloration syntaxique ;
  • la touche TAB produit des espaces (programmable, pour moi, c'est 3) ;
  • édition en mode colonne ;
  • annulation ;
  • copie de sauvegarde ;
  • patrons avec fonctions de base comme le nom du fichier et la date de création ;
  • possibilité d'ajouter des outils externes pour compléter ce qui manque.

II. Conventions de nommage

Une des façons d'obtenir du code clair est de s'en tenir à une convention de nommage des identificateurs qui soit cohérente et parlante. Voici mes recommandations.

II-A. Utilisation du caractère '_' (Underscore)

Ce caractère peut être utilisé comme séparateur visuel dans les identificateurs. Bien que ce soit techniquement possible, Je recommande de ne pas l'utiliser au début d'un identificateur, pour deux raisons principales :

  • La plupart de ces identificateurs sont réservés à l'implémentation du langage
  • L'aspect très inesthétique de ces identificateurs

II-B. Macros

Les macros ayant des usages multiples, il est difficile de faire une généralité. Dans les cas où elles représentent une constante, il est recommandé d'utiliser les majuscules. Dans les autres cas, le choix sera le même que pour l'identificateur qu'elles substituent. Cependant, si on cherche à insister sur le fait qu'une soi-disant fonction est en fait une macro, on peut utiliser les majuscules. En fait tout dépend du contexte.

II-C. Constantes

II-C-1. Conventions typographiques

Il est couramment admis que les constantes 'vraies' (macros, énumérations) doivent être écrites en majuscules. Les variables qualifiées const seront écrites comme des variables.

II-C-2. Identificateur

L'identificateur dépend du contexte. Il sert souvent à définir les valeurs des propriétés d'un objet (valeurs particulières, états, etc.). Il est recommandé d'utiliser un préfixe qui relie les constantes qui qualifient une même propriété.

Sélectionnez
  /*
  * Valeurs possibles de l'état du voyant
  *
  * OFF   : éteint
  * ON    : allume fixe
  * BLINK : clignotant
  */
  enum
  {
    VOYANT_STS_OFF,
    VOYANT_STS_ON,
    VOYANT_STS_BLINK,

    VOYANT_STS_NB
  };

II-C-3. Définition

Il y a deux façons de créer des valeurs constantes. Avec une macro ou avec une énumération.

Pour les constantes numériques de type int, il est recommandé d'utiliser les enum, qui présentent des avantages comme le typage, la numérotation automatique, ou l'interprétation textuelle automatique dans les débogueurs.

Pour les constantes numériques des autres types (long, unsigned etc.) et les chaînes, il n'y a pas le choix, il faut utiliser les macros. Ne pas oublier les qualificateurs de macros pour les valeurs numériques autres que int : Suffixes de macros numériques u ou U unsigned :

l ou L | long
ul ou UL | unsigned long
f ou F | float

Ne pas oublier non plus que pour qu'une constante soit de type double, au moins un des membres doit être de type double, donc comprendre un '.' dans son expression :

Sélectionnez
  #define PI (22/7)

est un entier qui vaut 3.

Sélectionnez
  #define PI (22/7.0)

est un double qui vaut environ 3.14.

II-D. Types

Le langage C permet de créer un alias sur un type existant plus ou moins complexe ou un autre alias existant à l'aide du mot clé typedef.

II-D-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules. Il est recommandé d'utiliser des suffixes tels que :

Suffixes de types (alias)
_e | typedef enum
_u | typedef union
_s | typedef struct
_a | tableau (array)
_f | fonction

Il est recommandé de ne pas utiliser le suffixe '_t', car il est réservé par la norme POSIX pour définir des alias de types.

II-E. Objets (Variables)

II-E-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules.

II-E-2. Identificateur

Il est un principe général de bonne conception qui veut que la longueur des identificateurs des objets et des fonctions soit proportionnelle à leur portée.

Les variables introduisent en plus la notion de durée de vie (duration). Il est bon qu'au premier coup d'œil, on sache exactement à quoi on a affaire. Il est bon de pouvoir aussi identifier rapidement les variables en fonction de leurs propriétés. Les plus remarquables sont :

  • simple (plain) ;
  • pointeur ;
  • tableau statique ;
  • tableau dynamique ;
  • chaîne de caractères.

Il est donc recommandé d'utiliser les préfixes suivants :

Préfixes d'identificateurs
 | simple
p_ | pointeur
a_ ou sa_ | tableau statique(static array)
da_ | tableau (dynamic array)
s_ | chaîne de caractères (string)

et en ce qui concerne la durée de vie et la portée :

Préfixes indiquant la durée de vie et la portée
 | Portée | Durée de vie
 | bloc | bloc
g_ | bloc | programme
S_ | module | programme
G_ | programme | programme

II-F. Fonctions

II-F-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules. Il existe des cas où l'on a besoin de 2 mots pour nommer une fonction. Il n'est évidemment pas question d'accoler ces deux mots en minuscule, car le code serait vite illisible. Il existe 2 pratiques répandues :

  • coller les mots en mettant une majuscule au début de chaque mot :
Sélectionnez
OuvrirFichier()

ou (variante «à-la-Java») à partir du 2ème mot :

Sélectionnez
ouvrirFichier()
  • séparer les mots en mettant un souligné (underscore) entre chaque mot.
Sélectionnez
ouvrir_fichier()

C'est une question de goût, le principal est d'être clair et cohérent. La seconde méthode a ma préférence.

II-F-2. Identificateur

Pour une fonction, on choisit plutôt un identificateur qui exprime une action (verbe, verbe + substantif).

III. Organisation du code source

Il est pratique d'adopter une disposition logique et cohérente dans les fichiers sources et d'en-têtes. Il est d'usage d'utiliser un principe simple, qui consiste à définir ce que l'on doit utiliser. C'est pourquoi la disposition suivante est recommandée :

III-A. Fichiers sources (*.c)

III-A-1. Liste ordonnée des éléments pouvant être contenus dans un fichier source

  • inclusion des en-têtes (.h) nécessaires ;
  • définition des macros privées ;
  • définition des constantes privées ;
  • définition des types privés ;
  • définition des structures privées ;
  • définition des variables globales privées ;
  • définition des fonctions privées ;
  • définition des fonctions publiques ;
  • définition des variables globales publiques.

Voici par exemple, une liste de séparateurs utilisés pour structurer des fichiers sources :

Sélectionnez
  /* macros ============================================================== */
  /* constants =========================================================== */
  /* types =============================================================== */
  /* structures ========================================================== */
  /* private variables =================================================== */
  /* private functions =================================================== */
  /* internal public functions =========================================== */
  /* entry points ======================================================== */
  /* public variables ==================================================== */

III-B. Fichiers d'en-tête (*.h)

III-B-1. Règles d'or régissant la définition des fichiers d'en-tête

  • un fichier d'en-tête doit être protégé contre les inclusions multiples dans la même unité de compilation ;
  • un fichier d'en-tête doit être autonome[1] ;
  • un fichier d'en-tête ne doit inclure que le strict nécessaire à son autonomie.

[1] Pour s'en assurer, lors des tests de l'implémentation correspondante, le placer en 1ère position et ajouter ce qu'il manque dedans.

III-B-2. Liste ordonnée des éléments pouvant être contenus dans un fichier d'en-tête

  • inclusion des headers nécessaires et suffisants ;
  • définition des macros publiques ;
  • définition des constantes publiques ;
  • définition des types publiques ;
  • définition des structures publiques ;
  • déclaration des fonctions publiques ;
  • déclaration des variables globales publiques ;
  • [C99] définition des fonctions publiques inline.

Voici par exemple, une liste de séparateurs utilisés pour structurer des fichiers d'en-tête :

Sélectionnez
  /* macros ============================================================== */
  /* constants =========================================================== */
  /* types =============================================================== */
  /* structures ========================================================== */
  /* internal public functions =========================================== */
  /* entry points ======================================================== */
  /* public variables ==================================================== */
  /* inline public functions  ============================================ */

III-B-3. Comment bien utiliser les séparateurs

A l'origine, on a un fichier avec main() (par exemple main.c, comme ça, on voit tout de suite de quoi on parle). main() est le point d'entrée (unique) de ce module. On peut donc écrire :

Sélectionnez
/* main.c */
#include

/* entry points ======================================================== */

int main (void)
{
   puts ("hello world");

   return 0;
}

Le programme évoluant, on a maintenant une fonction privée (static) :

Sélectionnez
/* main.c */
#include

/* private functions =================================================== */

static void hello(void)
{
   puts ("hello world");
}

/* entry points ======================================================== */

int main (void)
{
   hello();

   return 0;
}

Nouvelle évolution, on crée un bloc fonctionnel (BF) hello composé pour le moment d'une seule fonction. On retrouve donc la structure habituelle avec 3 fichiers :

Sélectionnez
#ifndef H_HELLO
#define H_HELLO
/* hello.h */

/* entry points ======================================================== */

void hello(void);
Sélectionnez
/* hello.c */
#include "hello.h"
#include

/* entry points ======================================================== */

void hello(void)
{
   puts ("hello world");
}
Sélectionnez
/* main.c */
#include "hello.h"

/* entry points ======================================================== */

int main (void)
{
   hello();

   return 0;
}

On fait dans le luxe et on décompose la fonction hello() en plusieurs petites fonctions (un peu théorique, mais c'est pour montrer le principe).

Sélectionnez
/* hello.c */
#include "hello.h"
#include

/* private functions =================================================== */

static void h(void)
{
   printf ("hello");
}

static void w(void)
{
   printf ("world");
}

static void spc(void)
{
   putchar (' ');
}

static void eol(void)
{
   putchar ('\n');
}

/* entry points ======================================================== */

void hello(void)
{
   h();
   spc();
   w();
   eol();
}

Le reste (interface, allocation) est inchangé.

Attention, étape ultime et subtile.

Le fichier d'implémentation de hello est devenu tellement gros qu'il nécessite lui-même un découpage. Il va donc falloir distinguer ce qui est accessible à l'utilisateur de hello (les points d'entrées) et les fonctions 'internes', c'est à dire connues de hello mais pas de l'application, d'où cette distinction, qui se traduit aussi par des headers distincts :

Sélectionnez
/* hellotools.c */
#include "hellotools.h"
#include

/* internal public functions =========================================== */
void hello_spc(void)
{
   putchar (' ');
}

void hello_eol(void)
{
   putchar ('\n');
}
Sélectionnez
#ifndef H_HELLOTOOLS
#define H_HELLOTOOLS
/* hellotools.h */

/* internal public functions =========================================== */
void hello_spc(void);
void hello_eol(void);

#endif
Sélectionnez
/* hello.c */
#include "hellotools.h"
#include "hello.h"

#include

/* private functions =================================================== */
static void h(void)
{
   printf ("hello");
}

static void w(void)
{
   printf ("world");
}

/* entry points ======================================================== */
void hello(void)
{
   h();
   hello_spc();
   w();
   hello_eol();
}

Cela revient à créer plusieurs niveaux de visibilité…

III-B-4. Protection contre les inclusions multiples

Les fichiers d'en-tête pouvant être inclus dans des fichiers sources, comme dans des fichiers d'en-tête, et ce, dans un ordre non spécifié, il est indispensable de se prémunir contre les risques d'inclusions multiples dans la même unité de compilation.

Sélectionnez
  #ifndef IDENTIFICATEUR
  #define IDENTIFICATEUR

  /* zone protégée contre les inclusions multiples */

  #endif /* guard */

Le principe de protection étant basé sur la définition d'une macro, cette macro doit être unique afin d'éviter les protections abusives.

Personnellement, j'utilise le format suivant :

Sélectionnez
  H_<initials>_<file>_<date>
  initials ::= En majuscule : initiales du développeur, nom de la société etc.
  file     ::= En majuscule : nom du fichier (sans l'extension)
  date     ::= <year><month><day><hour><minute><second>
  year    ::= année (0000-9999)
  month   ::= mois (01-12)
  day     ::= jour (01-31)
  hour    ::= heure (00-23)
  minute  ::= minute (00-59)
  second  ::= seconde (00-59)

Par exemple :

Sélectionnez
  H_ED_EXEMPLE_20040529115235

Le choix de mettre le H_ en tête est justifié. En effet, il évite de briser une règle du langage C qui dit qu'un identificateur commençant par E suivi d'une majuscule est réservé pour l'implémentation. En fait ils sont utilisés pour implémenter les valeurs symboliques de errno.

IV. Comment bien organiser son développement

Le langage C autorise la compilation séparée. Cette technique permet de créer des unités de compilations (compile units) séparées. Une bonne maîtrise de la programmation permet de réaliser du code indépendant ou tout au moins suffisamment indépendant pour être testable individuellement.

Voici un exemple élémentaire de compilation séparée reprenant le célèbre "Hello world!". Le code est divisé en 3 sources. Un fichier d'interface (hello.h), un fichier d'implémentation (hello.c) et un fichier d'application (main.c) :

main.c
Sélectionnez
  /* main.c */

  #include "hello.h"

  int main (void)
  {
    hello ();
    return 0;
  }
hello.h
Sélectionnez
  #ifndef H_HELLO
  #define H_HELLO

  /* hello.h */

  void hello (void);

  #endif /* guard */
hello.c
Sélectionnez
  /* hello.c */

  #include "hello.h"
  #include <stdio.h>

  void hello (void)
  {
    puts ("Hello world!");
  }

Vidéo sonore (flash) expliquant comment faire sous Code::Blocks 8.02

Afin de bien organiser les fichiers, je conseille de créer des répertoires pour les sources et pour les fichiers de sortie :

Organisation des répertoires
Type | Répertoire | Fichiers | nom.ext
Génération | ./ | make | Makefile
Source | inc/ | inclus | *.h
Source | src/ | source | *.c
Sortie | obj/ | objets intermédiaires | *.obj
Sortie | exe/ | exécutable | *.exe

et par conséquent, d'adopter l'organisation suivante pour les répertoires :

Sélectionnez
  hello/Makefile
  hello/inc/hello.h
  hello/src/hello.c
  hello/src/main.c
  hello/obj/hello.obj
  hello/obj/main.obj
  hello/exe/hello.exe

Afin de garantir un comportement correct du code et des outils de développement, je recommande que les noms de répertoires et de fichiers soient tous écrits en minuscule. Je rappelle que si on utilise un outil de gestion de version comme CVS, il est extrêmement difficile de modifier la casse des répertoires et même des fichiers après coup. C'est donc une démarche à adopter dès la première ligne de code.

Pour compiler et exécuter ce code sous gcc avec cette organisation, on peut utiliser ce Makefile (DJGPP). (Remplacer <tab> par une véritable tabulation) :

Sélectionnez
  # Makefile HELLO

  # Paths
  DSRC = src
  DINC = inc
  DOBJ = obj
  DEXE = exe
  DLIB = d:/djgpp/lib

  # Compiler flags
  CFLAGS = -I$(DINC)

  # Commands
  CC = gcc -c $(CFLAGS)
  LK = ld
  RM = del

  # Project list
  OBJS =      \
  $(DOBJ)/hello.obj  \
  $(DOBJ)/main.obj   \

  #Rebuild all target
  all: $(DEXE)/hello.exe

  # Clean all target
  clean:
  <tab>cd $(DOBJ)
  <tab>$(RM) *.obj
  <tab>cd ../$(DEXE)
  <tab>$(RM) *.exe
  <tab>cd ..

  # main target (OBJS + init code + library)
  $(DEXE)/hello.exe : $(OBJS)
  <tab>$(LK) -o $(DEXE)/hello.exe $(OBJS) $(DLIB)/crt0.o -lc

  # objects

  # The hello.c file
  $(DOBJ)/hello.obj : $(DSRC)/hello.c          \
            $(DINC)/hello.h           \
            Makefile
  <tab>$(CC) $(DSRC)/hello.c -o$(DOBJ)/hello.obj

  # The main.c file
  $(DOBJ)/main.obj : $(DSRC)/main.c            \
            $(DINC)/hello.h           \
            Makefile
  <tab>$(CC) $(DSRC)/main.c -o$(DOBJ)/main.obj

Avec une utilisation comme suit (MS-DOS) :

  • génération ou mise à jour :
Sélectionnez
  D:\HELLO>make
  gcc -c -Iinc src/hello.c -oobj/hello.obj
  gcc -c -Iinc src/main.c -oobj/main.obj
  ld -o exe/hello.exe obj/hello.obj obj/main.obj  d:/djgpp/lib/crt0.o -lc
  • effacement complet :
Sélectionnez
  D:\HELLO>make clean
  cd obj
  del *.obj
  cd ../exe
  del *.exe
  cd ..

Pour en savoir plus, consultez l'article l'outil MAKE

V. Comment construire sa bibliothèque

Le langage C utilisant lui même la notion de bibliothèque de fonctions, il est logique que cette possibilité soit offerte aux développeurs. Les avantages sont bien connus :

  • identification et bonne gestion du code réutilisable ;
  • interface bien défini (headers) ;
  • gain de productivité (réutilisation,moins de code à compiler).

Les détails de réalisation dépendent des outils de développement, mais le principe d'organisation du code est le même. La compilation d'une bibliothèque est évidemment une application directe du principe de la compilation séparée.

S'ajoutent quelques règles de bon sens telles que l'indépendance du code vis à vis de l'application. Cela concerne particulièrement les sorties qui, si il le faut, seront implémentées par des 'callbacks' (détails dans cet article : Concevoir et réaliser un composant logiciel en C ). Plus que jamais, les globales seront évitées.

Pour réaliser la bibliothèque, il faut créer un projet rassemblant les fichiers d'interface (headers : .h) et les fichiers d'implémentation (sources : .c). Évidemment, il ne doit pas y avoir de fonction main().

Ensuite, après compilation classique, et sans faire d'édition de lien, bien évidemment, on utilise un 'librarian' qui est un outil particulier faisant partie des outils de développements courants, et qui sert à créer la bibliothèque. Le résultat est un fichier dont l'extension dépend de l'outil. (par exemple, .lib ou .a).

V-A. Outil de développement gcc

Tout d'abord, avec gcc, il y a des règles de nommage à respecter. En effet, le nom du fichier produit doit impérativement commencer par lib et son extension doit être .a (comme archive).

Ensuite, l'outil s'appelle ar (ar.exe sous DOS/Windows) comme archiver. Les détails d'appels en mode commande sont à lire dans la documentation de gcc. Comme toujours en mode ligne de commande, un Makefile simplifie et automatise la tâche de production du code.

Les exemples suivants utilisent le fichier d'interface hello.h et le fichier d'implémentation hello.c décrits dans l'article sur la compilation séparée. La bibliothèque à créer s'appelle libhello.a. Elle ne sert évidemment qu'à illustrer le propos.

V-B. Création d'une bibliothèque sous Code::Blocks 8.02

  • File > New > Project ;
  • sélectionner 'static library' ;
  • sélectionner un répertoire <racine> et taper le nom de la bibliothèque : libhello /!\ IMPORTANT /!\ ;
  • le répertoire <racine>/libhello est créé automatiquement ; c'est le répertoire de travail ;
  • Debug et Release sont cochées, ne rien changer ;
  • cliquer sur [finish].

Dans le 'workspace', une ligne en gras a été ajoutée : 'libhello'. Un fichier main.c a été créé. On peut le retirer du projet :

  • Clic droit > Remove from project

Il faut maintenant ajouter les 2 fichiers source (.c et .h). Si il n'y sont pas déjà, les déplacer (ou les créer) dans le répertoire du projet (<racine>/libhello), puis :

  • sur la ligne 'Biblio. Hello' : click droit ;
  • Add files ;
  • vérifier qu'on est bien dans le répertoire <racine>/libhello ;
  • (on en profite pour supprimer main.c) ;
  • sélectionner hello.h et hello.c ;
  • valider ;
  • compiler.

La fenêtre de compilation doit donner quelque chose comme ceci :

Sélectionnez
-------------- Build: Debug in libhello ---------------

Compiling: hello.c
Linking static library: libhello.a
ar.exe: creating libhello.a
Output size is 3.63 KB
Process terminated with status 0 (0 minutes, 0 seconds)
0 errors, 0 warnings

On voit que hello.c a été compilé, que ar.exe a été invoqué et que libhello.a a été crée. On peut maintenant créer un petit programme de test avec le main.c et la bibliothèque. Le plus simple pour le moment est de la créer dans le même répertoire.

Sélectionnez
  Project   : Test hello
  Compiler  : GNU GCC Compiler (called directly)
  Directory : C:\dev\hello\
  --------------------------------------------------------------------------------
  Switching to target: default
  Compiling: main.c
  Linking console executable: C:\dev\hello\test.exe
  .objs\main.o: In function `main':
  C:/dev/hello/main.c:7: undefined reference to `hello'
  collect2: ld returned 1 exit status
  Process terminated with status 1 (0 minutes, 2 seconds)
  1 errors, 0 warnings

Bien sûr, le main.c tout seul ne suffit pas, il faut ajouter la bibliothèque au projet :

  • sur la ligne 'Test biblio. Hello' : clic droit ;
  • build options > Linker > Add > [...] > libhello.a > Ouvrir > NON > OK > OK ;
  • compiler.

On obtient alors :

Sélectionnez
  Project   : Test hello
  Compiler  : GNU GCC Compiler (called directly)
  Directory : C:\dev\hello\
  --------------------------------------------------------------------------------
  Switching to target: default
  Linking console executable: C:\dev\hello\test.exe
  Process terminated with status 0 (0 minutes, 0 seconds)
  0 errors, 0 warnings

et le fameux

Sélectionnez
  Hello world!

  Press ENTER to continue.

tant espéré !

VI. Comment bien configurer son compilateur

Le compilateur dispose de moyens d'analyse du code qui peuvent être mis à profit pour détecter toutes sortes d'erreurs. Il est donc important de savoir configurer son compilateur pour qu'il signale au mieux les anomalies pouvant se produire dans le code (Warnings). C'est ensuite au programmeur de réagir et, soit de justifier ou d'expliquer l'avertissement, soit de corriger le code.

La première des vérifications à faire est de s'assurer que l'on compile avec le bon compilateur. Certaines extensions ou réglages par défaut font que c'est parfois le compilateur C++ qui est invoqué au lieu du compilateur C. Ces quelques lignes placées au début de chaque fichier source (.c) permettent de détecter cette erreur :

Sélectionnez
#ifdef __cplusplus
#error This source file is not C++ but rather C. Please use a C-compiler
#endif

Si l'erreur se produit, vérifier les réglages et l'extension. Celle-ci doit impérativement être .c (minuscule), et non .cpp, ni .C (majuscule)

VI-A. gcc

gcc est le 'gnu c compiler', version GNU du cc Unix pour GNU/Linux. Il a été porté sur de nombreuses plate-formes dont Windows (les plus connues sont MinGW et CygWin).

Par défaut, le niveau d'avertissement (Warnings) est très laxiste. Il est fortement recommandé d'utiliser la configuration minimale suivante (C90) :

Sélectionnez
-Wall -Wextra -ansi -O -Wwrite-strings -Wstrict-prototypes -Wuninitialized
-Wunreachable-code

Sur les anciennes version de gcc (< 3.4.x), remplacer -Wextra par -W.

Si la version de gcc le permet, il est possible d'ajouter ces options :

Sélectionnez
-Wno-missing-braces  -Wno-missing-field-initializers

Experts uniquement

Il est aussi possible de procéder à un réglage 'fin'. Voici comment je configure gcc en mode 'paranoïaque' (C90) :

Sélectionnez
-O2 -Wchar-subscripts -Wcomment -Wformat=2 -Wimplicit-int
-Werror-implicit-function-declaration -Wmain -Wparentheses
-Wsequence-point -Wreturn-type -Wswitch -Wtrigraphs -Wunused
-Wuninitialized -Wunknown-pragmas -Wfloat-equal -Wundef
-Wshadow -Wpointer-arith -Wbad-function-cast -Wwrite-strings
-Wconversion -Wsign-compare -Waggregate-return -Wstrict-prototypes
-Wmissing-prototypes -Wmissing-declarations -Wmissing-noreturn
-Wformat -Wmissing-format-attribute -Wno-deprecated-declarations
-Wpacked -Wredundant-decls -Wnested-externs -Winline -Wlong-long
-Wunreachable-code

Attention ce réglage révèle certains défauts dans les headers de MinGW. La correction est possible, mais uniquement si on sait ce qu'on fait. Je n'en dirai donc pas plus ici.

Le détail de chaque paramètre est expliqué dans la documentation de gcc.

VI-B. Réglages dans Code::Blocks

  • Settings > Compiler > Onglet 'Compiler' > Onglet 'Other options' ;
  • copier/coller les options ;
  • OK ;
  • régénérer (Ctrl-F11).

S'assurer qu'une coche ne traine pas dans l'onglet de configuration du compilateur.

Plus d'informations sur Code::Blocks.

VI-C. Réglages dans wxDev-C++ (remplace Dev-C++ devenu obsolète)

  • outils > Options du compilateur ;
  • dans la zone de saisie "Ajouter les commandes suivantes lors de l'appel du compilateur", copier/coller les options que je recommande ;
  • valider ;
  • régénérer (Ctrl-F11).

VI-D. Microsoft Visual C++ (6, 2005, 2008 etc.)

  • propriétés du projet -> C/C++ -> général ;
  • mettre le niveau (level) de warning à 4.

Plus d'informations sur Microsoft Visual C++ Express 2008 et 2010

VI-E. Borland C / Turbo C

  • Option -> Compiler -> Messages -> Display ;
  • (*) ALL.

VII. Ressources

  • Recommended C Style and Coding Standards ;
  • MISRA ;
  • Macros prédéfinies.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Historique du langage C

Table des matières

  • I. Origines
  • II. Évolutions
  • III. Héritage historique

Cet article est un bref historique du langage C ; de ses origines (CPL, BCPL, B) à ses évolutions (K&R, ANSI, C++, ...)

I. Origines

Le langage C possède trois ancêtres : le CPL, le BCPL et le B.

I-A. Le CPL

Le langage CPL (pour Combined Programming Language) a été conçu au début des années 1960 par les universités de Cambridge et de Londres. C'était un grand projet académique consistant à créer un langage englobant beaucoup de concepts. Le CPL devait notamment être fortement typé, avec de nombreux types comme des nombres entiers, réels, booléens, caractères, tableaux, listes...

Le CPL était trop complexe pour l'époque et il semble que personne n'ait réussi à terminer l'écriture d'un compilateur. Ce langage a disparu sans laisser de trace quelque part dans les années 1970.

I-B. Le BCPL

Le langage BCPL (pour Basic CPL) a été conçu à Cambridge en 1966 par Martin Richards. L'année suivante, il alla au MIT et écrivit un premier compilateur. Le BCPL est une version fortement simplifiée du langage CPL avec notamment un seul type de donnée, le mot machine, c'est-à-dire le nombre typique de bits que le processeur d'un ordinateur traite en une instruction-machine (addition, soustraction, multiplication, copie...). La notion est devenue un peu floue avec les processeurs actuels qui peuvent traiter des données de toutes sortes de tailles. Cependant, on peut raisonnablement classer les Pentium et PowerPC parmi les processeurs 32 bits, contre 64 bits pour les Alpha, Itanium ou Opteron. Du temps du BCPL, on trouvait des architectures à mots de 40 bits, 36 bits, 18 bits, 16 bits...

Le BCPL opère sur les mots de la machine. Il est donc à la fois portable et proche du matériel, donc efficace pour la programmation système. Le langage BCPL a servi à écrire divers systèmes d'exploitation de l'époque, dont un (TripOS) qui s'est retrouvé partiellement porté sur l'Amiga pour devenir la partie AmigaDOS du système.

Mais aujourd'hui, le BCPL ne semble plus être utilisé que par son inventeur. C'est sans doute dû au fait que le langage C a repris et étendu la plupart des qualités de BCPL.

Référence : manuel BCPL

Exemple de code BCPL (tiré de Clive Feather on CPL and BCPL) :

MANIFEST ${ TOPFACT = 10 $} // Equivalent of "const int TOPFACT = 10"
LET infact (n) = VALOF
$(
  LET f, j = 1., 0.

  FOR i = 0 TO n             // Declares i for the next block only
  $(
    f #*:= j;                // := is assign, = is compare
    fact!i := f;             // assignment doesn't return a value
    j #+:= 1.
  $)
  RESULTIS f
$)
AND fact = VEC TOPFACT;      // As in B, allocates 0 to TOPFACT

I-C. Le B

Le langage B a été créé par Ken Thompson vers 1970 dans les laboratoires Bell d'AT&T. L'année précédente, il avait écrit en assembleur la première version d'UNIX sur un PDP-7 contenant 8 kilo mots de 18 bits. Lorsqu'il voulut proposer un langage sur ce nouveau système d'exploitation, il semble qu'il ait d'abord pensé à porter Fortran, mais que très vite (en une semaine) il conçut son propre langage : B.

Le B est une simplification de BCPL, un peu forcée par les limitations du PDP-7. Mais la syntaxe très sobre du B (et des commandes UNIX) toute en lettres minuscules correspond surtout aux goûts de Thompson. Le langage C a repris la syntaxe du B avec un minimum de changements.

Le langage B a été porté et utilisé sur quelques autres systèmes. Mais cette même année 1970, le succès du projet UNIX justifia l'achat d'un PDP-11. Celui-ci avait des mots machine de 16 bits, mais il était aussi capable de traiter des octets (24 ko de mémoire vive en tout) dans chacun desquels pouvait être stocké un caractère. Le B ne traitait que des mots machine, donc le passage de 18 à 16 bits n'était pas problématique, mais il était impossible de traiter efficacement les caractères de 8 bits. Pour bien exploiter les capacités de la machine, le B a donc commencé à être étendu en ajoutant un type pour les caractères...

Référence : Thompson's BManual

Exemple de code B identique au code BCPL ci-dessus (tiré de Clive Feather on CPL and BCPL) :

infact (n)
{
  auto f, i, j;   /* no initialization for auto variables */
  extrn fact;     /* "What would I do differently if designing
                   *  UNIX today?  I'd spell creat() with an e."
                   *  -- Ken Thompson, approx. wording */

  f = 1.;         /* floating point constant */
  j = 0.;
  for (i = 0; i <= n; ++i) {
    fact[i] = f =#* j;      /* note spelling =#* not #*= */
    j =#+ 1.;               /* #+ for floating add */
  }

  return (f);     /* at least, I think the () were required */
}

TOPFACT = 10;   /* equivalent of #define, allows numeric values only */
fact[TOPFACT];

I-D. Le C

Le langage C a été développé par un collègue de Ken Thompson, Dennis Ritchie qui pensait d'abord faire uniquement un New B ou NB. Mais en plus des caractères, Ritchie ajouta les tableaux, les pointeurs, les nombres à virgule flottante, les structures... 1972 fut l'année de développement la plus productive et sans doute l'année de baptême de C. En 1973, le langage C fut suffisamment au point pour que 90 % d'UNIX puisse être récrit avec. En 1974, les laboratoires Bell ont accordé des licences UNIX aux universités et c'est ainsi que le langage C a commencé à être distribué.

Références : The Development of the C Language, Very early C compilers and language

Exemple de code C identique aux codes BCPL et B ci-dessus (tiré de Clive Feather on CPL and BCPL) :

float infact (n) int n;
{
  float f = 1;
  int i;
  extern float fact[];

  for (i = 0; i <= n; ++i)
    fact[i] = f *= i;

    return d;
  }

#define TOPFACT 10
float fact[TOPFACT+1];

Voir Ken Thompson (assis) et Dennis Ritchie devant un PDP-11 fonctionnant avec UNIX vers 1972.

II. Évolutions

Une fois rendu public, le langage C a peu changé. Pratiquement toutes les extensions se sont faites dans C++, qui est une gigantesque extension du C. Une autre évolution du C est Objective-C, qui se concentre sur l'orientation objet. De nombreux autres langages comme Java, JavaScript ou C# ont largement repris la syntaxe du C, mais sans être compatibles.

II-A. 1978 - K&R C

#include <stdio.h>
main(argc,
     argv)
int argc;
char ** argv;
{
 printf("hello, world\n");
}

La plus ancienne version du langage C encore en usage a été formalisée en 1978 lorsque Brian Kernighan et Dennis Ritchie ont publié la première édition du livre Le langage C. Ce livre décrit ce qu'on appelle actuellement le K&R C, le C traditionnel, voire le vieux C. Peu après sa publication, de très nombreux compilateurs C ont commencé à apparaître.

Les programmes portables écrits dans les années 1980 sont donc en K&R C. De fait, quelques programmes très portables sont encore en K&R C (par exemple GNU Make). En effet, de nombreux systèmes commerciaux ne proposent qu'un compilateur K&R C en standard, le compilateur ANSI C devant être acheté séparément. Heureusement, la disponibilité presque universelle de GCC résout pratiquement ce problème.

II-B. 1983 - C++

#include <iostream.h>
int main(int argc,
         char * argv[])
{
 cout << "hello, world"
      << endl;
 return 0;
}

À partir de 1980, Bjarne Stroustrup a étendu le langage C avec le concept de classe. Ce langage étendu a d'abord été appelé C with Classes, puis le C++ en 1983. Le langage C++ a énormément évolué (surcharge d'opérateurs, héritage, références, types génériques, exceptions...), mais en restant le plus compatible possible avec C. Il est souvent possible de compiler un programme en C avec un compilateur C++.

II-C. 1983 - Objective-C

void main()
{
 printf("hello, world\n");
}

Le langage Objective-C a été créé par Brad Cox. C'est un strict sur ensemble de C. Il lui apporte un support de la programmation orientée objet inspiré de Smalltalk. Ce langage est à la base de NeXTSTEP. Avec le rachat de NeXT par Apple, l'Objective-C s'est retrouvé à la base de l'interface graphique Cocoa de Mac OS X.

II-D. 1989 - ANSI C

#include <stdio.h>
main(int argc,
     char * argv[])
{
 printf("hello, world\n");
 return 0;
}

Un comité de standardisation a été créé en 1983 pour éviter que les quelques ambiguïtés et insuffisances du K&R C ne conduisent à des divergences importantes entre les compilateurs. Il a publié en 1989 le standard appelé ANSI C. Il a repris quelques bonnes idées du C++ comme les prototypes de fonction, tout en restant très compatible avec K&R C.

Le degré de compatibilité atteint est suffisant pour que Kernighan et Ritchie n'aient eu qu'à adapter légèrement la seconde édition du livre Le langage C pour qu'il décrive ANSI C. En plus, selon Stroustrup, tous les exemples de cette seconde édition sont aussi des programmes C++.

Le standard ANSI C est devenu l'évolution la plus utilisée du C.

II-E. 1998 - Standard C++

#include <iostream>
int main(int argc,
         char * argv[])
{
 std::cout << "hello, world"
           << std::endl;
}

Le C++ a évolué très longtemps. Ce n'est qu'en 1998, 8 ans après la création d'un comité, que le standard ISO C++ a été officiellement publié. Ce standard est tellement complexe (et légèrement incohérent), qu'en 2003, le compilateur GCC ne le met pas complètement en œuvre, et ce n'est pas le seul. Les syntaxes obsolètes et problématiques de K&R C ont été abandonnées ; pour le reste, la compatibilité avec le C reste excellente.

II-F. 1999 - C99

#include <stdio.h>
int main(int argc,
         char * argv[])
{
 printf("hello, world\n");
}

Le dernier-né de l'histoire est le C99 (standard ISO de 1999) qui est une petite évolution de l'ANSI C de 1989. Les évolutions ne sont pas compatibles avec le C++ et n'ont pas attiré beaucoup d'intérêt.

GCC supporte le C99 et le noyau Linux en tire profit. Côté compatibilité, le support des syntaxes obsolètes de K&R C a été abandonné.

III. Héritage historique

De nombreuses propriétés étranges du langage C s'expliquent par l'évolution historique du langage.

III-A. Pointeurs et tableaux

III-A-1. Déclaration avec []

Les toutes premières versions du langage C ne permettaient pas d'utiliser * pour déclarer un pointeur, il fallait utiliser []. Les sources du premier compilateur C montrent cependant qu'une variable déclarée avec [] était un pointeur, déréférençable avec l'opérateur * et incrémentable avec l'opérateur ++ :

* Exemple des premières versions de C, désormais illégal ! */
int np[];   /* Ceci déclarait en fait un pointeur sur un entier  */
/*...*/
*np++ = 1;  /* qui était utilisable comme les pointeurs actuels. */

Les premières versions du C n'avaient donc en fait que des pointeurs. La trace la plus visible de cet héritage se retrouve dans la déclaration classique de la fonction main :

int main(int argc, char* argv[]);

Encore aujourd'hui, l'opérateur [] dans un paramètre formel de fonction déclare un pointeur. Cet usage trompeur est inusité, sauf pour main. La déclaration précédente est donc tout à fait équivalente à :

int main(int argc, char** argv);

Il est impossible de déclarer un paramètre formel de type tableau. En revanche, il est possible de déclarer un pointeur sur un tableau. Les déclarations suivantes sont équivalentes, car f reçoit en fait un pointeur de tableau de 13 int :

void f(int tab[2][13]);  /* Le 2 est ignoré ! */
void f(int tab[][13]);
void f(int (*tab)[13]);  /* Usage le plus clair */

III-A-2. Pointeur et premier élément de tableau

Le fait que int t[]; déclarait un pointeur explique les liens très étroits entre pointeurs et tableaux. En effet, si t pointait sur un int et si ce dernier était suivi en mémoire d'autres int, alors t pointait sur le premier élément d'un tableau d'int.

Aujourd'hui int t[]; déclare un tableau de nom t. Mais un nom de tableau est automatiquement utilisé comme un pointeur sur son premier élément dans un contexte ou un pointeur est attendu, c'est-à-dire pour initialiser un pointeur ou comme opérande de +, -, *, [], ==, !=, <, <=, >, >=, !... En revanche, un nom de tableau ne se comporte pas comme un pointeur avec les opérateurs unaires &, ++, --, sizeof ou à gauche de =.

void f(int* q)  /* la fonction f prend un pointeur d'entier */
{
  int *p, t[4]; /* p est un pointeur d'entier, t un tableau de 4 entiers */
  p = t;        /* assigne &t[0] (adresse du premier élément de t) à p */
  *t = 6;       /* assigne 6 au premier élément de t */
  p[1] = 6;     /* assigne 6 au second élément de t */
  *(t+2) = 6;   /* assigne 6 au troisième élément de t */
  t[3] = 6;     /* assigne 6 au quatrième élément de t */
  f(t);         /* initialise q avec &t[0] (adresse du premier élément de t) */
}

III-A-3. Opérateur []

Alors que les tableaux n'existaient pas encore, l'opérateur d'indexation [] existait déjà. Ses opérandes sont donc un pointeur et un entier : l'expression E1[E2] est équivalente à *((E1)+(E2)) et une des deux expressions doit être de type pointeur et l'autre de type entier. Si une des deux expressions est un nom de tableau, elle sera alors automatiquement convertie en un pointeur sur le premier élément. Le résultat de l'addition est un pointeur sur l'élément voulu, déréférencé par *.

À noter que E1[E2] est équivalent à *((E1)+(E2)) qui est équivalent à *((E2)+(E1)) qui est équivalent à E2[E1]. Les deux expressions suivantes sont donc équivalentes :

t[3] = 6;  /* *(t+3) = 6; */
3[t] = 6;  /* *(3+t) = 6; */

III-B. Conversions de type

Le C a tendance à convertir automatiquement les valeurs entre des types qui ne partagent aucune sémantique :

char c = 133333.14;  /* conversion double -> char */
float x = 'a';       /* conversion int -> float (char -> float en C++) */
char * p = 123;      /* conversion int -> char* possible en vieux C */

Cela vient du fait que les types n'ont pas été ajoutés au langage C pour permettre au compilateur de faire des vérifications sémantiques. À l'origine, les types ont été ajoutés simplement pour traiter des variables de différentes tailles, notamment des caractères de 8 bits lorsque les mots machine mesurent 16 bits. Les conversions automatiques de type étaient alors une puissante fonctionnalité.

À mesure que le C a été utilisé pour de grands projets, le besoin de vérifications de type s'est plus fait sentir. Les compilateurs ont commencé à signaler les conversions entre pointeur et entier ou entre pointeurs incompatibles. Les conversions automatiques impliquant des pointeurs ont finalement été retirées de C++. En effet, la vérification statique des types est un des points forts de C++.

III-C. int implicite

Le type char a été créé pour représenter les caractères, les autres variables tenant dans un mot machine. Le type int a été donné au mot machine, mais le nom du type est resté optionnel pour déclarer une variable int. Ainsi, le fait d'ajouter les types n'a pas rendu les sources existantes incompatibles.

La règle du int implicite est même restée couramment utilisée jusqu'au K&R C. Elle est encore admise dans ANSI C et C++, mais a disparu du C++ standard et de C99. On peut noter que la déclaration d'une variable automatique locale sans donner son type requiert l'usage du mot clé auto, devenu totalement obsolète en C moderne :

/* Exemple de int implicite en K&R C */
/*int*/ *g;
/*int*/ main()
{
  auto /*int*/ i;
  g = &i;
  *g = 0;
  return i;
}

III-D. Conversions arithmétiques

Lorsqu'une opération arithmétique implique des valeurs de différents types, les opérandes sont d'abord automatiquement convertis vers un type commun. Ces conversions sont naturelles et donnent des résultats intuitifs (sauf lors d'un mélange entre types signé et non signé).

int func() {
  short s = 2;
  long l = 1;
  /* Valeur de s convertie en long, puis addition en long, puis résultat long
     converti en float pour être assigné à f. */
  float f = l + s;
}

Une particularité est qu'aucune opération ne s'effectue avec une précision inférieure à int. Autrement dit, si l'on additionne deux short, ils seront chacun convertis en int avant que l'opération ait lieu. C'est encore un héritage de l'importance du mot machine :

short f(short a, short b) {
  /* Valeurs de a et b converties en int avant l'addition.
     Le résultat int de l'addition est converti en short pour être
     retourné, d'où possibilité d'avertissement du compilateur ! */
  return a + b;
}

III-E. Paramètres de fonction

Les vérifications de type des paramètres de fonction ont été ajoutées progressivement au langage. Mais seuls le C++ et le C99 rendent les vérifications obligatoires.

III-E-1. Déclaration implicite de fonction

En C, il est possible d'appeler une fonction déclarée nulle part. Dans ce cas, le compilateur se crée une déclaration implicite de la fonction d'après l'appel. Bien sûr, un exécutable peut être produit uniquement si l'éditeur de liens trouve la fonction appelée dans un fichier objet. Cependant, si la déclaration implicite du compilateur n'est pas compatible avec la fonction trouvée, alors l'éditeur de lien ne le verra pas, et l'exécution du programme sera erronée.

int main(void)
{
  /* La fonction printf n'est déclarée nulle part, mais présente dans la libc,
     donc un exécutable peut être produit par un compilateur C. */
  printf("hello\n");  /* Appel compatible avec printf, affichera "hello". */
  printf(123);   /* Appel non compatible, causera une erreur d'exécution. */
  return 0;
}

III-E-2. Déclaration de fonction

En C, une déclaration de fonction ne donne pas son prototype, c'est-à-dire qu'une déclaration de fonction ne donne ni le nombre ni le type des paramètres et ne permet donc pas de vérifications :

int main(void)
{
  int printf();  /* Déclaration de la fonction printf, ne précise pas
                    les paramètres de printf. */
  printf(123);   /* Appel non compatible, causera une erreur, mais
                    seulement à l'exécution ! */
  return 0;
}

III-E-3. Style K&R C de définition de fonction

Les définitions de fonction de style K&R C ne sont pas utilisées pour vérifier le type, ni même le nombre des paramètres :

/* Style de programmation K&R C */
void repeter(c, s, n)  /* 3 paramètres */
char c, *s;            /* n implicitement int */
{
  while (n--) s[n] = c;
}

main()
{
   char t[10+1];
   t[10] = 0;
   repeter('a', t, 10);  /* écrit aaaaaaaaaa dans t */
   repeter(123);         /* erreur, mais seulement à l'exécution ! */
   printf("%s\n", t);
}

III-E-4. Promotion des paramètres

Avec les fonctions de style K&R C, les compilateurs ne connaissent le type des paramètres des fonctions appelées. Ils promeuvent donc les entiers (short, char... en int ou unsigned) et les float en double, avant de les passer à la fonction. La promotion en int laisse des traces dans plusieurs prototypes de fonction de la bibliothèque standard qui traitent des caractères :

int putchar(int c);
int isalnum(int c);
int tolower(int c);
int toupper(int c);
void * memset(void * s, int c, size_t n);

Malgré l'introduction des prototypes, on trouve encore une trace de cela avec les fonctions à nombre variable de paramètres, comme int printf(const char*,...);. Pour afficher un short, on utilise le formatage %d comme pour un int. En effet, le short est promu en int avant d'être passé à printf :

#include <stdio.h>
int main()
{
  int i = 1; short s = 2;
  printf("%d %d\n", i, s);  /* affiche 1 2 */
  return 0;
}

III-E-5. Prototypes, void, procédure

Les prototypes ont été introduits en C sous l'impulsion de C++. En C++, le prototype de toute fonction appelée doit être connu. En outre, en C++, int f(); est un prototype qui indique que la fonction f n'a pas de paramètres. En C, int f(); est une déclaration et ne précise rien sur les paramètres. En C, le prototype d'une fonction f sans paramètres est int f(void);.

              /* En C        */    /* En C++     */
int f();      /* déclaration */    /* prototype  */
int f(int);   /* prototype   */    /* prototype  */
int f(int i)  /* définition  */    /* définition */
{ return i; }
int f() {}    /* erreurs     */    /* définition */

La dernière ligne est une erreur en C à deux titres. D'abord, la redéfinition de fonction est interdite (et la surcharge n'existe pas). Ensuite, la définition n'est pas compatible avec le prototype. En revanche, on remarque que le prototype est compatible avec la déclaration.

Pour C++, il y a simplement deux prototypes et deux définitions, pour deux fonctions surchargées.

L'introduction du type void rend également possible la création de procédures en C, c'est-à-dire des fonctions ne retournant rien.

III-F. Chaînes littérales de caractères

À l'origine de C, les chaînes littérales de caractères étaient des tableaux statiques anonymes de caractères, initialisés avec les caractères. Lors de la standardisation de C, le mot clé const a été introduit et les chaînes littérales ont été rendues constantes pour pouvoir être partagées ou mises en mémoire morte. Il aurait donc été logique de donner aux chaînes littérales le type « tableau de caractères constants.» Cependant, l'usage suivant était extrêmement répandu :

char t[] = "hello";
char * p = "hello";

Or, cet usage aurait causé un avertissement du compilateur à cause de la conversion de const char[] en char[]. Dans le standard ANSI C, les chaînes littérales gardent donc le type char[], bien qu'elles soient considérées comme constantes.

Référence : explication de Dennis Ritchie.

Lors de la standardisation de C++, le type des chaînes littérales a tout de même été changé en « tableau de caractères constants ». Cela est nécessaire pour choisir la bonne fonction parmi deux fonctions surchargées dont seule la constance du pointeur change. Toutefois la construction char*p="texte"; reste permise, mais dépréciée.


Les listes chaînées en C.

1 - La liste chaînée simple.

Introduction :

Qu'est-ce qu'une liste chaînée ?
C'est un système informatique qui permet la sauvegarde dynamique de données en mémoire tout comme des variables ou tableaux mais sans se préoccuper de leur nombre et en rendant leur allocation plus transparente.
On dit liste chaînée car les données sont chaînées les unes avec les autres. On accède aux données à l'aide d'un ou deux points d'entrées qui se situent la plus part du temps aux extrémités de la liste.
Dans la pratique ces points d'entrées seront des pointeurs soit sur le premier ou le dernier élément de la liste voir sur les deux ou même mobile.
Les éléments de la liste sont chaînés entre eux à l'aide de pointeurs sur leurs éléments suivants ou précédents voir sur les deux. Ces pointeurs doivent donc faire partie de l'élément. Ce qui nous orientera vers l'utilisation d'une structure du langage C (struct). Cette structure aura donc la particularité d'avoir au moins un pointeur sur des variables du même type qu'elle. Ce pointeur servira à relier les éléments de la liste entre eux. La structure contiendra bien sûr aussi les données que l'on veut mémoriser.
Si vous débutez et que vous avez quelques difficultés avec les pointeurs, je vous propose d'aller voir cet article : Les pointeurs du C et C++.

Une liste concrète :

Quoi de mieux qu'un exemple pour mieux assimiler tout cela :
Notre choix va s'orienter sur une pile (dernier entré, premier sorti). Pourquoi ce choix car c'est une liste chaînée simple. Pour rester simple et ne pas alourdir l'exemple elle mémorisera un seul entier (int), mais le fait d'utiliser une structure nous permettrait d'utiliser une architecture de données plus complexe. Elle aura un seul point d'entrée : un pointeur sur le sommet de la pile (dernier élément de la liste chaînée). Le but est donc que ce pointeur pointe toujours sur le sommet de la pile, donc si un élément est ajouté au sommet de la pile le pointeur devra pointer dessus, mais pour ne pas égarer l'élément précédent le nouvel élément devra pointer sur le précédent...
On peut donc définir notre structure de cette façon :

        typedef struct pile
        {
                int valeur;
                struct pile *prec;
        } pile ;

Maintenant que notre structure est définie il faut créer la pile, nous avons dit que l'accès à la pile se ferait à l'aide d'un pointeur sur son sommet, et bien créons ce pointeur :

        pile *MaPile = NULL;

Il est très important de l'initialiser à NULL, ceci nous indique en premier lieu que la pile est vide, mais il sera aussi utile pour parcourir la pile, ce que nous verrons plus loin dans cet article.
Pour en faciliter la compréhension, agrémentons cet exemple d'un schéma donnant une représentation visuelle du chaînage de la pile :

La première chose à quoi l'on pense est d'ajouter des éléments sur la pile. Nous allons dédier ceci à une fonction que l'on nommera "Push". Son but est donc de créer un nouvel élément qui sera donc du type de la structure précédemment définie, d'y mémoriser la valeur et le pointeur sur l'élément précédent (celui qui était au sommet de la pile avant l'ajout du nouvel élément). Le pointeur identifiant la pile (MaPile dans l'exemple) doit lui pointer sur l'élément que l'on vient d'ajouter, puisqu'il devient le sommet de la pile.

void Push(pile **p, int Val)
{
        pile *element = malloc(sizeof(pile));
        if(!element) exit(EXIT_FAILURE);     /* Si l'allocation a échouée. */
        element->valeur = Val;
        element->prec = *p;
        *p = element;       /* Le pointeur pointe sur le dernier élément. */
}

La fonction reçoit comme paramètres la valeur que l'on veut mémoriser mais aussi un pointeur sur le pointeur identifiant la pile. Pourquoi un pointeur de pointeur ? Ceci afin de passer l'adresse du pointeur à la fonction pour que celle-ci puisse le modifier.
Dans la fonction nous créons en premier lieu le nouvel élément (*element) avec l'instruction malloc. Nous lui affectons sa valeur, mais aussi l'adresse de l'élément précédent qui est en fait le sommet actuel de la pile et enfin nous affectons le pointeur identifiant la pile par pointeur déréférencé avec l'adresse de l'élément que l'on vient de créer afin qu'il devienne le sommet de la pile.
Exemple d'utilisation de la fonction Push :

     Push(&MaPile, 10);

Ajouter des éléments c'est bien ! Mais sans doute vous voudrez aussi en retirer. Nous allons créer pour cela une fonction que l'on nommera "Pop" dont le but n'est que l'opération inverse de la fonction Push.
La fonction doit donc nous retourner la valeur, libérer la mémoire allouée pour l'élément, affecter au pointeur l'adresse de l'élément précédent afin qu'il devienne le sommet de la pile.

int Pop(pile **p)
{
        int Val;
        pile *tmp;
        if(!*p) return -1;     /* Retourne -1 si la pile est vide. */
        tmp = (*p)->prec;
        Val = (*p)->valeur;
        free(*p);
        *p = tmp;       /* Le pointeur pointe sur le dernier élément. */
        return Val;     /* Retourne la valeur soutirée de la pile. */
}

Exemple d'utilisation de Pop, retirant et affichant un élément de la pile :

      printf("%d\n",Pop(&MaPile));

Nous allons maintenant créer une fonction qui va parcourir la pile, nommée Length elle retournera le nombre d'élément de la pile.

int Length(pile *p)
{
        int n=0;
        while(p)
          {
              n++;
              p = p->prec;
          }
        return n;
}

Le pointeur identifiant la pile ne devant pas être modifié, elle recevra donc seulement le pointeur comme paramètre et non pas son adresse.
La boucle while parcourt la pile en utilisant le membre prec de la structure mémorisant l'adresse de l'élément précédent de la pile et s'arrête dès que le pointeur est égal à NULL c'est à dire au bas de la pile d'où l'intérêts d'initialiser le pointeur de départ à NULL.
Exemple d'utilisation de la fonction Length :

      printf("Nb d'elements : %d\n",Length(MaPile));

Nous allons y ajouter une autre fonction afin de vider la pile et de libérer la mémoire : Clear

void Clear(pile **p)
{
        pile *tmp;
        while(*p)
          {
             tmp = (*p)->prec;
             free(*p);
             *p = tmp;
          }
}

On parcourt la pile comme dans la fonction Length, au retour le pointeur identifiant la pile sera NULL puisque égal au membre prec du premier élément et toute la mémoire libérée.
Exemple d'utilisation de la fonction Clear :

       Clear(&MaPile);

Nous allons ajouter une dernière fonction View qui n'est pas spécialement utile pour une pile mais qui nous servira de test dans l'exemple de fin d'article.

void View(pile *p)
{
        while(p)
          {
             printf("%d\n",p->valeur);
             p = p->prec;
          }
}

C'est aussi une fonction qui parcourt la pile dans le but de visualiser tous ses éléments. Ses éléments seront affichés en ordre inverse de leur introduction, la pile étant parcouru à l'envers.
Exemple d'utilisation de la fonction View :

        View(MaPile);

Vu de l'exterieur cette pile est donc identifiée par un unique pointeur que l'on passe comme paramètre aux fonctions gérant cette pile, ce qui en rend la manipulation assez simple.

Codes sources de l'exemple :

Pour plus de lisibilité et de possibilité de réutilisation de cette pile, nous séparerons le code de la pile de son utilisation.

Pile.h :

#ifndef CGI_PILE_H
#define CGI_PILE_H

 /*  Structure représantant un élément de la pile. */

        typedef struct pile
        {
                int valeur;
                struct pile *prec;
        } pile ;

#ifdef __cplusplus
extern "C" {
#endif

 /*  Push empile une valeur sur la pile. */

        void Push(pile **, int);

 /*  Pop retire la dernière valeur empilée sur la pile. */

        int Pop(pile **);

 /*  Clear vide la pile. */

        void Clear(pile **);

 /*  Length retourne le nombre d'élément de la pile. */

        int Length(pile *p);

 /*  Affiche la totalité de la pile en commençant par le sommet. */

        void View(pile *);

#ifdef __cplusplus
}
#endif

#endif
Pile.c :
#include <stdlib.h>
#include <stdio.h>

#include "Pile.h"

/*************************************************************************/

void Push(pile **p, int Val)
{
        pile *element = malloc(sizeof(pile));
        if(!element) exit(EXIT_FAILURE);     /* Si l'allocation a échouée. */
        element->valeur = Val;
        element->prec = *p;
        *p = element;       /* Le pointeur pointe sur le dernier élément. */
}
/*************************************************************************/

int Pop(pile **p)
{
        int Val;
        pile *tmp;
        if(!*p) return -1;     /* Retourne -1 si la pile est vide. */
        tmp = (*p)->prec;
        Val = (*p)->valeur;
        free(*p);
        *p = tmp;       /* Le pointeur pointe sur le dernier élément. */
        return Val;     /* Retourne la valeur soutirée de la pile. */
}

/*************************************************************************/

void Clear(pile **p)
{
        pile *tmp;
        while(*p)
          {
             tmp = (*p)->prec;
             free(*p);
             *p = tmp;
          }
}
/*************************************************************************/

int Length(pile *p)
{
        int n=0;
        while(p)
          {
              n++;
              p = p->prec;
          }
        return n;
}

/*************************************************************************/

void View(pile *p)
{
        while(p)
          {
             printf("%d\n",p->valeur);
             p = p->prec;
          }
}

Voici un exemple d'utilisation de la pile que nous venons de construire.

main.c :

#include <stdlib.h>   /* Pour la fonction system. */
#include <stdio.h>
#include "Pile.h"

int main()
{
        pile *MaPile = NULL;   /* Impératif de l'initialiser à NULL */

        Push(&MaPile, 10);
        Push(&MaPile, 25);
        Push(&MaPile, 33);
        Push(&MaPile, 12);      /* Empile 4 valeurs. */

        puts("Affichage de la pile :");
        View(MaPile);       /* Affiche la totalité de la pile. */
        puts("------");

        printf("Nb d'elements : %d\n",Length(MaPile));
        puts("------");

        puts("Deux valeurs soutirees de la pile :");
        printf("%d\n",Pop(&MaPile));   /* Affiche deux valeurs */
        printf("%d\n",Pop(&MaPile));   /* soutirées de la pile. */
        puts("------");

        puts("Affichage de la pile :");
        View(MaPile);       /* Affiche la totalité de la pile. */
        puts("------");

        Clear(&MaPile);        /* Vide la pile. */

        Push(&MaPile, 18);      /* Empile une valeur. */

        puts("Affichage de la pile apres vidage et ajout d'une valeur :");
        View(MaPile);       /* Affiche la totalité de la pile. */
        puts("------\n");

        Clear(&MaPile);    /* Vider la pile avant de quitter. */

#ifdef _WIN32
        system("PAUSE");  /* Pour la console Windows. */
#endif
        return 0;
}

Afin de continuer sur le thème des listes chaînées, je vous propose un autre document : une liste triée où les éléments sont insérés dans la liste de façon à être triés dés leur insertion. " http://chgi.developpez.com/sortlist/ "


Bien débuter en Langage C

Table des matières

  • I. Qu'est-ce que le C ?
  • II. Je débute :
  • III. Je veux approfondir mes connaissances :
  • IV. Où puis-je trouver des exercices à faire ?
  • V. Je veux aller plus loin :
  • VI. J'ai tout lu mais il y a des choses que je n'ai pas compris, que faire?
  • VII. Conclusion

Vous désirez apprendre le Langage C ? Vous ne savez pas par quoi commencer ? Ce guide va vous aider dans vos premiers pas, du choix de l'éditeur jusqu'à celui du livre ou des tutoriels en ligne ansi que de nombreux liens pour vous permettre de vous exercer et d'aller plus loin !

I. Qu'est-ce que le C ?

Créé au début des années 70 par Dennis Ritchie et Brian Kernighan, le Langage C reste encore aujourd'hui un des langages les plus utilisé au monde !

Ce langage de programmation est à la base des systèmes d'exploitations que nous connaissons aujourd'hui ou au moins du noyau de ces systèmes comme par exemple Unix/Linux. Le Langage C a justement été créé pour un seul et unique but au départ, développer un système d'exploitation (Unix) mais au fil du temps, grâce à sa puissance, il a été adopté par une large communauté de développeurs ce qui a permis au langage d'évoluer et surtout d'être standardisé.

Ce langage est multi plate-forme, c'est-à-dire qu'un programme que vous créez par exemple sous Linux pourrais tout à fait être re-compilé sous Windows, BeOS, Amiga, etc.... sans devoir changer grand chose (voir rien du tout) dans le code source si vous respectez les normes en vigueur ANSI/ISO.

II. Je débute :

Les débuts ne sont jamais très aisés, notamment en ce qui concerne le choix des livres, la recherche de tutoriels en ligne et dans le choix des éditeurs ou EDI (Environnement de Développement Intégré). Les catégories ci-dessous vous permettrons de vous acquitter de cette tâche souvent très longue et désagréable pour les débutants, tout ce que vous avez à faire, c'est de suivre ces différentes parties !

II-A. Quels livres/tutoriels en ligne puis-je lire ?

  • Liste des cours complets sélectionnés par developpez.com
  • Initiation au Langage C - Emmanuel Delahaye
  • Codage en Langage C - Emmanuel Delahaye
  • Initiation au langage C - Jessee Michaël C. Edouard

II-B. Quels livres puis-je lire ?

  • Méthodologie de la programmation en C

II-C. Quels sont les outils dont j'ai besoin ?

Il vous faut pour bien commencer, un EDI (Environnement de Développement Intégré). Plusieurs EDI sont à votre disposition mais, la plupart d'entre eux ne sont disponibles que pour un seul système d'exploitation.

C'est là que Code::Blocks se distingue des autres EDI car il est portable, ce qui signifie qu'il est disponible sur au moins les systèmes les plus utilisés et plus connus soit Windows, Linux et Mac OS X.

Ci-dessous vous trouverez un lien direct de téléchargement de Code::Blocks pour chacun de ces systèmes:

Image personnelle Sous Windows, vous avez la possibilité d'utiliser la version dite "Stable" ce qui est souvent préférable: Téléchargement de la version stable: 12.11
Cette version est livrée avec la suite de compilation MingW !
Image personnelle Si votre système est un système Linux, vous avez alors le choix entre différents package selon votre distribution: Téléchargement des versions 12.11 pour Linux A noter également que sur les distributions Debian et Ubuntu vous pouvez télécharger et installer ce programme avec la commande apt-get install à partir de votre console.
Image personnelle Voici la version 8.02 si vous disposez de Mac OS X: Téléchargement de la version Mac

Vous pouvez également utiliser les versions en cours de développement, ca ne reste que des versions beta donc plus ou moins stables: Nightly Builds

Pour l'installation de Code::Blocks vous pouvez consulter les tutoriels suivants: Guides d'installation de Code::Blocks (Windows - Linux)

Voici également une présentation et test de cet EDI : Test de l'EDI C/C++ Code::Blocks

III. Je veux approfondir mes connaissances :

Vous avez acquis de bonnes bases en Langage C, vous désirez approfondir vos connaissances soit dans le C ANSI/ISO ou voir les normes POSIX ou encore vous plonger dans la programmation spécifique à Linux ? Les livres et tutoriels ci-dessous vous sont proposés par developpez.com !

III-A. Quels livres/tutoriels en ligne puis-je lire ?

  • Liste des tutoriels hébergés sur developpez.com
  • Notes sur le Langage C - Emmanuel Delahaye
  • Concevoir un composant logiciel en C - Emmanuel Delahaye
  • Les types abstraits de données (ADT) - Emmanuel Delahaye
  • Notes sur les réseaux - Emmanuel Delahaye
  • Les Threads POSIX.1 - Emmanuel Delahaye
  • Comment se fabriquer des entrées solides en C - Emmanuel Delahaye

III-B. Quels livres puis-je lire ?

  • Le langage C - Norme ANSI, 2ème édition
  • Exercices corrigés sur le langage C - Solutions des exercices du manuel de langage C de Kerrighan et Ritchie
  • C en action
  • Maîtrise des algorithmes en C
  • Programmation système en C sous Linux : Signaux, processus, threads, IPC et sockets

III-C. Quels outils supplémentaires sont à ma disposition ?

Dans cette section vous sont proposés des outils de débogage (pratique indispensable pour avoir des applications solides et avec le moins de failles et bugs possibles), des outils pour détecter des fuites mémoire, etc... Certains d'entre eux ne sont disponibles que sous un système, ils sont donc classés par OS :

Image personnelle Le debogueur phare de Microsoft et sans doute le meilleur sur ce système d'exploitation reste encore celui qui est fournit par défaut dans les solutions Visual Studio. La seule chose qui change ici, c'est que pour l'utiliser, il vous faut utiliser également l'éditeur Visual C++ (ici Visual C++ 2010 Express Edition) qu'on peut télécharger gratuitement sur la msdn: Télécharger et installer Visual C++ 2010

Sur le lien ci-contre vous pouvez voir les étapes pour son installation Microsoft Visual C++ 2010 Express : Téléchargement, installation et configuration
Voici également un tutoriel vous permettant d'apprendre à utiliser ce debogueur: Utiliser efficacement le débogueur de Visual Studio
Image personnelle Sous Linux vous avez l'embarat du choix, voici sans doute le meilleur que l'on puisse faire à l'heure actuelle tout en restant le maximum dans la simplicité !

DDD :
Cet outil permet le débogage de vos applications. Ici plusieures solutions s'offrent à vous, vous pouvez télécharger ce programme directement depuis le site officiel soit les liens suivants:

Télécharger les sources
Télécharger les paquets Debian (Ubuntu également)
Télécharger les paquest RPM

Le mieux reste sans doute d'utiliser l'outil de recherche/téléchargement/installation de votre distribution comme apt-get par exemple sous Debian et dérivés !
Voici en complément, un tutoriel d'initiation à DDD: Découverte du débogueur DDD

Valgrind :
Ceci est une suite d'outils de débogage, il permet entre autre de détecter les fuites mémoires dans vos programmes.
Comme pour DDD, vous pouvez soit télécharger le programme par le biais des programmes de votre distribution ou bien télécharger directement depuis le site officiel:

Télécharger Valgrind depuis le site officiel...
Image personnelle GDB :
Cet outil de débogage est en fait le même que celui sous Linux et dont DDD (cité plus haut sur Linux) en est une interface graphique (le programme GDB d'origine est en ligne de commande !). Vous pouvez télécharger ce programme depuis le site officiel:

Télécharger GDB depuis le site officiel...

XCode :
Si vous préférez les interfaces utilisateurs (ce qui reste encore le plus pratique), vous pouvez également avoir à votre disposition XCode qui est un EDI pour Mac OS X et qui intègre un "Frontend"(une interface graphique pour un programme en ligne de commande).

IV. Où puis-je trouver des exercices à faire ?

Il est toujours dur de trouver des exercices relativement corrects, les liens qui vous sont proposés ci-dessous vont aussi bien vers des exercices en rapport avec les maths qu'au développement de petits jeux en mode console et divers problèmes à résoudre !

Vous pouvez, pour commencer, trouver des défis sur notre page Défis C. Vous y trouverez les annales avec les diverses solutions proposées par les participants ainsi que le défis en cours s'il y a. Si cela ne vous suffit pas, voici quelques liens:

  • Récréomath
  • TopDev
  • Franc - IOI

Vous pouvez également entreprendre la création d'un petit jeu (toujours en mode console) comme par exemple :

  • Mastermind :
    • La première serait une version simple où le joueur devine la combinaison mise en place par l'ordinateur.
    • La seconde serait une version où c'est cette fois-ci, c'est l'ordinateur qui devine votre combinaison.
  • Puissance 4
  • Jeu de Dames

Ces exercices sont surtout intéressants du point de vue algorithmique et au niveau de l'IA (Intelligence Artificielle) !

V. Je veux aller plus loin :

Vous en avez assez des programmes en mode console ? Vous désirez faire des programmes avec une interface graphique (GUI: Graphic User Interface/Interface Utilisateur Graphique) ou voir même comment on peut créer des jeux vidéos en 2D et en 3D ? Regardez en-dessous !

V-A. Comment créer des applications graphiques ?

V-A-a. GTK+ (la GUI portable) :

La bibliothèque GTK+ permet de créer des programmes avec interfaces utilisateur tout en restant portable ce qui signifie que vous pouvez le faire tourner (sous quelques conditions tout de même) sur les systèmes comme Windows, Linux, et bien d'autres !

Voici des liens vous permettant d'apprendre à utiliser cette bibliothèque :

  • Cours GTK 2
  • GTK+ par l'exemple
  • La page GTK+ sur developpez.com
  • La FAQ GTK+ sur developpez.com

V-A-b. L'API Win32 (uniquement pour Windows) :

Contrairement à GTK+, l'API de Windows n'est pas portable et est sans doute un peu plus dure à prendre en main mais elle permet de faire des applications natives Win32 tout en ayant accès à toutes les fonctions de l'API Système !

Voici quelques liens pour vous familiariser avec cette API :

  • Tutorial d'initiation à la programmation avec l'API Windows
  • Cours sur l'API Windows en C
  • La page Windows sur developpez.com

V-B. Comment créer des jeux ?

V-B-a. La 2D :

La bibliothèque en vogue pour créer des jeux 2D (ou autre application multimédia) est la SDL (Simple Directmedia Layer). Cette bibliothèque est en effet surtout spécialisée pour la création de jeux vidéos en 2 Dimensions, elle est très simple d'emploi et on la maîtrise assez rapidement.

Voici quelques liens pour vous familiariser avec cette bibliothèque :

  • Tutoriaux SDL - Romain Perruchon
  • Introduction à la SDL
  • La page Jeux sur developpez.com
  • La FAQ SDL sur developpez.com

V-B-b. La 3D :

Connue de tous, la bibliothèque de référence dans la création de jeux vidéos 3D est sans conteste OpenGL !

Voici quelques liens pour vous permettre d'apprendre à l'utiliser :

  • Tutoriels NeHe en français - La traduction française des plus célèbres tutoriels OpenGL
  • La page Jeux sur developpez.com
  • La FAQ OpenGL sur developpez.com

VI. J'ai tout lu mais il y a des choses que je n'ai pas compris, que faire ?

Pas de panique ! Vous pouvez toujours poser vos questions sur les forums de developpez.com !
Voici les forums de developpez.com sur lesquels vous pouvez poser vos questions :

  • Forum : C
  • Forum : Code::Blocks
  • Forum : GTK+
  • Forum : SDL
  • Forum : OpenGL
  • Forum : Programmation Windows
  • Forum : Programmation Linux

Sans oublier les FAQ des rubriques :

  • FAQ : C
  • FAQ : GTK+
  • FAQ : SDL
  • FAQ : OpenGL

VII. Conclusion

J'espère que ce guide vous aura permis d'y voir un peu plus clair et de commencer votre apprentissage du Langage C plus sereinement.
L'équipe C/C++/Jeux vous souhaite une bonne programmation !


Les erreurs courantes en C

Table des matières

  • Confusion entre == et =
  • Confusion entre le ET logique et le ET binaire
  • Confusion entre le OU logique et le OU binaire
  • Problème de boucle
  • Fonctions retournant un caractère (*getc*)
  • Récupérer le texte entré au clavier
  • Tester la fin d'un fichier
  • Vider le buffer d'un flux entrant
  • Les chaînes de caractères
  • Allouer correctement de la mémoire
  • Tester le retour des fonctions
  • Oubli d'un break dans un switch
  • Division entière

Ce document regroupe une liste des erreurs les plus couramment rencontrées par un programmeur en C, ainsi que des bonnes habitudes à prendre pour éviter d'obtenir des comportements indéfinis.

Confusion entre == et =

Pour vérifier une égalité, il faut utiliser l'opérateur == (constitué de deux signes égal) hors il est très facile de n'en taper qu'un seul. Ceci a pour conséquence de réaliser une affectation :

if (size = 0)

Dans ce cas, l'expression retourne 0, donc le bloc if ne sera jamais exécuté. Voici deux solutions possibles :

  • Bien régler son compilateur : l'option -Wall de gcc signale ce genre d'erreur
  • Utiliser la syntaxe :
    if (0==i)

    Ceci permet, en cas de faute de frappe, d'obtenir une erreur de la part du compilateur.

Confusion entre le ET logique et le ET binaire

Il est facile de confondre le ET logique (&&) qui retourne 0 ou 1 (en s'arrêtant au premier argument s'il est faux) et le ET binaire (&) qui, quant à lui, évalue ses deux opérandes. Cette confusion est plus difficile à détecter que la précédente puisque même un compilateur bien réglé ne vous avertira pas de l'erreur.

Confusion entre le OU logique et le OU binaire

Le problème est similaire au précédent sauf que cette fois ci, il porte sur le OU logique (||) et sa version binaire (|). Le second opérande du OU logique n'est pas évalué si le premier est vrai.

Problème de boucle

Une erreur aussi courante et tout aussi difficile à détecter que les précédentes, il s'agit du point virgule en trop au niveau de l'instruction de boucle :

int i;
for (i = 0; i < 10; i++);
   printf ("%d\n", i);

Dans ce cas, la boucle for sera exécutée puis i sera affiché avec la valeur de sortie de la boucle (10 dans notre cas). L'erreur est encore plus gênante avec une boucle while puisque le programme peut entrer dans une boucle sans fin.

Fonctions retournant un caractère (*getc*)

Les fonctions fgetc, getc et getchar permettent de récupérer un caractère sur un flux d'entrée (stdin pour getchar). Ces fonctions retournent un int et c'est à ce niveau que réside le problème en particulier lorsque la variable utilisée pour stocker le retour de ces fonctions est un char :

char c;
while ( (c = getchar ()) != EOF)
  ...

Pour expliquer d'où vient le problème, voici les cast implicites réalisés par le compilateur :

while ( (int)(c = (char)getchar ()) != EOF)
  ...

Or le caractère EOF (qui marque la fin d'un fichier : End Of File) est un caractère invalide généralement égale à -1 mais il peut parfaitement être égal à 128, dans ce cas on dépasse la capacité de stockage d'un char et l'on se retrouve avec un résultat égale à -128 : la condition du while sera toujours fausse, le programme boucle indéfiniment!

Récupérer le texte entré au clavier

La première fonction généralement présentée pour récupérer une saisie de l'utilisateur est scanf or il s'agit de l'une des fonctions les plus difficiles à maîtriser en C. Voici le genre de code que l'on peut trouver dans certains livres d'initiation :

int nombre;
scanf ("%d", &nombre);

Premièrement le risque d'oublier le & devant les paramètres de scanf est assez important. Deuxièment que se passe-t-il si l'utilisateur entre autre chose qu'un entier (du texte par exemple)? On obtient un comportement indéfini! Et dans le cas de la saisie de texte, voici un autre code d'exemple :

char texte[20];
scanf ("%s", texte);

Tout aussi dangereux! Qu'est ce qui empêche l'utilisateur de saisir plus de 19 caractères? Dans ce cas rien(1)! La fonction gets peut sembler pratique pour récupérer du texte mais l'on retrouve le même problème du contrôle de la taille du texte saisi.
Il reste plus qu'une fonction(2) : fgets, c'est la seule fonction qui doit être utilisée pour récupérer une chaîne de caractère. Pour récupérer des nombres, il suffit de convertir le résultat de fgets grâce à strtol ou strtod (atoi et atof étant obsolètes).

Tester la fin d'un fichier

On trouve souvent ce genre de test :

FILE *stream;
...
while (!feof (stream))
...

La fonction feof n'est pas faite pour cela. Il faut tester le retour de la fonction de saisie (fgets ou fgetc) qui retourne NULL ou EOF en cas d'erreur ou de fin de fichier et ensuite seulement on peut utiliser feof pour connaître la cause de l'arrêt de la lecture du flux.

Vider le buffer d'un flux entrant

La fonction fflush permet de vider le tampon des flux sortants et uniquement ceci! Son utilisation dans le cas d'un flux entrant (stdin par exemple) entraîne un comportement indefini, on utilisera donc :

int ch;
while( (ch = fgetc(fp)) != EOF && ch != '\n' )
  ;

Les chaînes de caractères

En C, les chaînes de caractères n'existent pas, il s'agit de tableaux de caractères terminés par un caractère nul (\0). Lors de la création d'une chaîne, il ne faut pas oublier de prévoir une case de plus pour y stocker ce marqueur de fin de chaîne :

char src[] = "Developpez";
char *dst = NULL;

dst = malloc (sizeof (*dst) * 10);
strcpy (dst, src);

L'utilisation de dst comme une chaîne de caractère provoquera un comportement indéfini. Pour les mêmes raisons, il n'est pas possible de comparer deux chaînes grâce à l'opérateur d'égalité (==) :

char str1[] = "abc";
char str2[] = "abc";

if (str1 == str2)
  printf ("str1 et identique a str2");
else
  printf ("str1 et different de str2");

Ce code affichera toujours que les deux chaînes sont différentes puisque l'on compare les adresses des variables str1 et str2. Pour que le code fasse ce que l'on souhaite, il faut utiliser la fonction strcmp.
Pour en finir avec les chaînes de caractères, l'expression :

char *texte = "Developpez";

N'alloue pas de mémoire pour stocker la chaîne, on se contente de mémoriser l'adresse de la chaîne. Hors rien de garantie que celle-ci est stockée dans une zone de mémoire accéssible en écriture, par précaution, il vaut mieu écrire :

const char *texte = "Developpez";

Allouer correctement de la mémoire

Pour allouer de la mémoire, il faut utiliser la fonction malloc (ou calloc au choix) qui demande la taille de la zone à allouer. Il est d'usage courant d'utiliser le type pour déterminer cette taille :

int *tab = malloc (sizeof (int) * BUFSIZ);

Avant de continuer je tiens à signaler qu'il est déconseillé de caster le retour de malloc, puisque premièrement c'est inutil(3) et secondement cela peut cacher l'absence d'inclusion de stdlib.h.

Il peut arriver que le type de tab soit modifié, dans ce cas il faut reprendre tous les appels à malloc pour les modifier et en cas d'oubli la sanction peut être lourde de conséquence. C'est pourquoi je vous conseille d'utiliser le nom de la variable pour retrouver la taille de l'élément pointé :

int *tab = malloc (sizeof (*tab) * BUFSIZ);

Dans ce cas plus de problème! Et si on modifie le nom de la variable? Si vous oubliez un ancien nom dans le fichier source, c'est le compilateur qui se chargera de vous le rappeler.

La fonction free gère parfaitement le cas où l'adresse qui lui ai passée en argument est NULL, il est donc inutile d'écrire ceci :

if (p != NULL)
  free (p);

Tester le retour des fonctions

Certaines fonctions peuvent échouer (je pense en particulier à malloc et fopen) il est donc bon de tester leurs valeurs de retour pour savoir si l'on peut continuer. Pour illustrer ceci, voici comment utiliser correctement la fonction realloc pour ne pas avoir de surpise :

void *tmp = NULL;
char *p = NULL;
...
tmp = realloc (p, sizeof (*p) * BUFSIZ);
if (tmp != NULL)
{
   p = tmp;
   /* Le programme continue normalement */
}
else
{
   free (p);
   /* Il faut informer l'utilisateur du manque de
   memoire et quitter proprement l'application */
}

Oubli d'un break dans un switch

L'instruction switch n'est qu'un goto déguisé qui saute vers l'instrcution case correspondante et c'est tout! Si vous écrivez ceci :

int val = 1;
switch (val)
{
   case 1:
      printf ("val=1\n");
   case 2:
      printf ("val=2\n");
}

Le programme affichera les deux textes, pour n'afficher qu'un texte par valeur, il faut ajouter l'instruction break :

int val = 1;
switch (val)
{
   case 1:
      printf ("val=1\n");
   break;
   case 2:
      printf ("val=2\n");
   break;
}

Le second break n'est pas indispensable mais cela évitera de l'oublier en cas d'ajout de nouvelles valeurs.

Division entière

Pour réaliser une division entière ou réelle, le C utilise le même opérateur (/). Par conséquent pour savoir quel résultat retourner, le compilateur se base sur le type des opérandes. Voici différents exemples avec le résultat obtenu :

double a = 1/2 /* = 0 */

double b = 1.0/2 /* = 0.5 */

int a = 1, b = 2;
double c = ((double) a)/b; /* = 0.5 */

(1)
Il existe un moyen de limiter la taille de la chaîne récupérée par scanf en utilisant les paramètres de formatage.
(2)
Rien ne vous empêche de coder votre propre fonction en utilisant fgetc, voici une exemple que j'utilise dans mes projets : getNextLine.
(3)
Le cast d'un pointeur void est inutile en C contrairement au C++ : Les incompatibilités entre le C et le C++


Les arbres.

Introduction aux arbres.

Table des matières

  • I. Introduction
  • II. Définitions
  • III. Implémentation
  • IV. Les fonctions de base sur la manipulation des arbres.
  • V. Algorithmes de base sur les arbres binaires
  • VI. Parcours d'un arbre
  • VII. Opérations élémentaires sur un arbre

Cet article présente la structure de données arborescente. Le langage support est le langage C mais vous trouverez aussi du pseudo code que vous pourrez adapter à votre guise.

I. Introduction

Cet article présente la structure de données arborescente appelé aussi arbre. Ce type de structure de données est très utilisé quelle que soit le type d'application. En effet, ce type de structure de données si elle est bien utilisée donne de très bonnes performances. On la retrouvera donc dans les systèmes de bases de données ou bien encore dans l'organisation d'un système de fichiers d'un système d'exploitation.

II. Définitions

Un arbre est une structure qui peut se définir de manière récursive : un arbre est un arbre qui possède des liens ou des pointeurs vers d'autres arbres. Cette définition plutôt étrange au premier abord résume bien la démarche qui sera utilisé pour réaliser cette structure de données.

Toutefois, cette définition est un peu vague, et nous allons introduire de nouvelles définitions pour mieux caractériser et identifier les arbres.

II-A. Arbres enracinés

On distingue deux grands types d'arbres : les arbres enracinés et les arbres non enracinés. Le premier type d'arbre est celui qui nous intéressera le plus. Un arbre enraciné est un arbre hiérarchique dans lequel on peut établir des niveaux. Il ressemblera plus à un arbre généalogique tel qu'on le conçoit couramment. Voici un exemple d'arbre enraciné :

Un arbre enraciné. Exemple d'arbre enraciné.

Le deuxième grand type d'arbre est un arbre non enraciné. Il n'y a pas de relation d'ordre ou de hiérarchie entre les éléments qui composent l'arbre. On peut passer d'un arbre non enraciné à un arbre enraciné. Il suffit de choisir un élément comme sommet de l'arbre et de l'organiser de façon à obtenir un arbre enraciné. Voici un exemple d'arbre non enraciné :

Arbre non enraciné Exemple d'arbre non enraciné

Vous remarquerez qu'il y a équivalence entre les deux arbres précédents. En effet, en réorganisant l'arbre non enraciné, on peut obtenir strictement le même arbre que le premier. En revanche, ce n'est qu'un exemple d'arbre enraciné que l'on peut effectuer avec cet arbre non enraciné, il en existera d'autres.

Dans la suite, nous nous intéresserons uniquement aux arbres enracinés.

II-B. Terminologie

Précisons maintenant un peu plus les termes désignant les différents composant d'un arbre.

Tout d'abord, chaque élément d'un arbre se nomme un noeud. Les noeuds sont reliés les uns aux autres par des relations d'ordre ou de hiérarchie. Ainsi on dira qu'un noeud possède un père, c'est à dire un noeud qui lui est supérieur dans cette hiérarchie. Il possède éventuellement un ou plusieurs fils.

Il existe un noeud qui n'a pas de père, c'est donc la racine de l'arbre. Un noeud qui n'a pas de fils est appelé un feuille. Parfois on appelle une feuille un noeud externe tout autre noeud de l'arbre sera alors appelé un noeud interne.

Voici donc un schéma qui résume les différents composant d'un arbre :

Les consituants d'un arbre Description des différents composants d'un arbre

II-C. Arité d'un arbre

On a aussi envie de qualifier un arbre sur le nombre de fils qu'il possède. Ceci s'appelle l'arité de l'arbre. Un arbre dont les noeuds ne comporteront qu'au maximum n fils sera d'arrité n. On parlera alors d'arbre n-aire. Il existe un cas particulièrement utilisé : c'est l'arbre binaire. Dans un tel arbre, les noeuds ont au maximum 2 fils. On parlera alors de fils gauche et de fils droit pour les noeuds constituant ce type d'arbre.

L'arité n'impose pas le nombre minimum de fils, il s'agit d'un maximum, ainsi un arbre d'arité 3 pourra avoir des noeuds qui ont 0,1,2 ou 3 fils, mais en tout cas pas plus.

On appelle degré d'un noeud, le nombre de fils que possède ce noeud.

La suite de cet article portera uniquement sur les arbres binaires puisque c'est le type d'arbre le plus basique à étudier mais aussi parce que tout ce que vous verrez sur un arbre binaire est valable pour un arbre n-aire.

De plus il est possible de passer d'un arbre n-aire à un arbre binaire. Voici un exemple de passage d'un arbre généalogique (qui par définition n'est pas forcément binaire) à un arbre binaire.

Voici donc l'arbre généalogique de base :

Les valois Arbre généalogique des Valois directs

En remarquant qu'on peut mettre sur les fils gauche les enfants et sur les fils droit les frères et soeurs on en déduit donc que l'on peut construire un arbre binaire à partir de l'arbre n-aire. Voici donc l'arbre que nous obtenons :

Les Valois en binaire Arbre généalogique mis sous forme d'arbre binaire

Comme vous pouvez le constater, il est assez difficile de lire ce genre d'arbre et généralement on remonte au même niveau les noeuds de la même génération. Ainsi un noeud n'a pas de fils gauche et de fils droit mais un fils et un frère. Voici à quoi peut ressembler l'arbre précédent dans ce cas:

Les Valois en binaire Arbre généalogique mis sous forme d'arbre binaire

On distingue alors mieux les générations : sur la même ligne nous avons les frères et soeurs.

II-D. Taille et hauteur d'un arbre.

On appelle la taille d'un arbre, le nombre de noeud interne qui le compose. C'est à dire le nombre noeud total moins le nombre de feuille de l'arbre.

On appele également la profondeur d'un noeud la distance en terme de noeud par rapport à l'origine. Par convention, la racine est de profondeur 0. Dans l'exemple suivante le noeud F est de profondeur 2 et le noeud H est de profondeur 3.

La hauteur de l'arbre est alors la profondeur maximale de ses noeud. C'est à dire la profondeur à laquelle il faut descendre dans l'arbre pour trouver le noeud le plus loin de la racine.On peut aussi définir la hauteur de manière récursive : la hauteur d'un arbre est le maximum des hauteur ses fils. C'est à partir de cette définition que nous pourrons exprimer un algorithme de calcul de la hauteur de l'arbre.

La hauteur d'un arbre est très importante. En effet, c'est un repère de performance. La plupart des algorithmes que nous verrons dans la suite ont une complexité qui dépend de la hauteur de l'arbre. Ainsi plus l'arbre aura une hauteur élevée, plus l'algorithme mettra de temps à s'exécuter.

II-E. Arbre localement complet, dégénéré, complet.

Un arbre binaire localement complet est un arbre binaire dont chacun des noeuds possèdent soit 0 soit 2 fils. Ceci veut donc dire que les noeuds internes auront tous deux fils. Dans ce type d'arbre, on peut établir une relation entre la taille de l'arbre et le nombre de feuille. En effet, un arbre binaire localement complet de taille n aura n+1 feuille. L'arbre suivant est localement complet :

Arbre localement complet Exemple d'arbre localement complet

Un arbre dégénéré (appelé aussi filiforme) est un arbre dont les noeuds ne possède qu'un et un seul fils. Cet arbre est donc tout simplement une liste chaînée. Ce type d'arbre est donc à éviter, puisqu'il n'apportera aucun avantage par rapport à une liste chaînée simple. On a alors une relation entre la hauteur et la taille : un arbre dégénéré de taille n a une hauteur égale à n+1. L'arbre suivant est un arbre dégénéré.

Arbre dégénéré Exemple d'arbre dégénéré

On appellera arbre binaire complet tout arbre qui est localement complet et dont toutes les feuilles ont la même profondeur. Dans ce type d'arbre, on peut exprimer le nombre de noeuds n de l'arbre en fonction de la hauteur h : n = 2^(h+1) -1.

Arbre complet Exemple d'arbre complet

III. Implémentation

Nous allons maintenant discuter de l'implémentation des arbres. Tout d'abord,définissons le noeud. Un noeud est une structure (ou un enregistrement) qui contient au minimum trois champs : un champ contenant l'élément du noeud, c'est l'information qui est importante. Cette information peut être un entier, une chaîne de caractère ou tout autre chose que l'on désire stocker. Les deux autres champs sont le fils gauche et le fils droit du noeud. Ces des fils sont en fait des arbres, on les appelle généralement les sous arbres gauches et les sous arbres droit du noeud. De part cette définition, un arbre ne pourra donc être qu'un pointeur sur un noeud.

Voici donc une manière d'implémenter un arbre binaire en langage C :

Sélectionnez

typedef struct node * tree;

struct node
{
       TElement value;
    tree left;
    tree right;
};

On remplacera le type TElement pas type ou la structure de données que l'on veut utiliser comme entité significative des noeuds de l'arbre.

De par cette définition, on peut donc aisément constater que l'arbre vide sera représenté par la constante NULL.

Maintenant, si on veut représenter un autre type d'arbre, nous avons deux solutions : soit nous connaissons à l'avance le nombre maximal de fils des noeud (ce qui veut dire que l'on connaît l'arité de l'arbre, soit nous ne la connaissons pas.

Dans le premier cas, nous pouvons utiliser un tableau pour stocker les fils. Ainsi pour un arbre d'arité 4 nous aurons l'implémentation suivante :

Sélectionnez

typedef struct noeud * tree;

struct node
{
    TElement value;
    tree child[4];
};

Ainsi dans ce cas, les fils ne seront pas identifiés par leur position (droite ou gauche) mais par un numéro.

La deuxième solution consiste à utiliser une liste chainée pour la liste des fils.

Sélectionnez

typedef struct node * tree;
typedef struct cell * list;

struct cell
{
    arbre son;
    list next;
};

struct node
{
    TElement value;
    list child;
};

Ce type d'implémentation est déjà un peu plus compliquée, en effet, nous avons une récursivité mutuelle. Le type list à besoin du type tree pour s'utiliser mais le type tree a besoin du type list pour fonctionner. Le langage C autorise ce genre de construction du fait que le type list et le type tree sont définis via des pointeurs (list est un pointeur sur une structure cell et le type tree est un pointeur sur une structure node).

Pour cet article, nous nous contenterons que de la toute première implémentation : l'implémentation des arbres binaires. Mais sachez qu'avec un peu d'adaptation, les algorithmes que nous allons voir sont parfaitement utilisables sur des arbres n-aires.

IV. Les fonctions de base sur la manipulation des arbres.

Afin de faciliter notre manipulation des arbres, nous allons créer quelques fonctions. La première détermine si un arbre est vide. Le pseudo code associé est simple :

fonction EstVide( T : arbre ) renvoie un booléen

    si T == Null alors
        renvoyer vrai;
    sinon
        renvoyer faux;
    fin si

La constante Null est différente suivant le langage que vous utiliserez, elle peut être null, nul, nil ou encore NULL. C'est à vous à adapter le code en fonction du langage dans lequel vous volez implémenter un module de manipulation d'arbres binaires. Nous partons simplement du principe que le langage que vous utilisez dispose du type pointeur (ou d'un équivalent).

Voici ce que peut donner cette fonction en langage C :

bool EstVide( tree T)
{
    return T == NULL;
}

On peut se demander tout simplement l'utilité d'une telle fonction. Tout simplement parce qu'il est plus simple de comprendre la signification de EstVide(T) plutôt que T==NULL. De plus il s'agit en fait d'un concept de la programmation objet qu'est l'encapsulation. Derrière ce mot barbare, se tient un concept très simple qui est l'abstraction de l'implémentation de la structure de données. Ainsi en utilisant la fonction EstVide sur un arbre T, nous n'avons pas à savoir que T est un pointeur. Cela facilite les choses.

On notera aussi que nous utilisons le type bool qui existe en C depuis la norme C99. Si vous ne disposez pas d'un compilateur compatible avec cette norme, vous devrez utiliser la méthode classique : renvoyer un entier (on rappellera que 0 signifie faux et tout autre valeur signifie vrai).

Maintenant, prenons deux fonctions qui vont nous permettre de récupérer le fils gauche ainsi que le fils droit d'un arbre. Il faut faire attention à un problème : le cas où l'arbre est vide. En effet, dans ce cas, il n'existe pas de sous arbre gauche ni de sous arbre droit. Pour régler ce problème nous décidons arbitrairement de renvoyer l'arbre vide comme fils d'un arbre vide. Voici ce que celà peut donner en pseudo code :

fonction FilsGauche( T : arbre ) renvoie un arbre

    si EstVide(T) alors
        renvoyer arbre_vide;
    sinon
        renvoyer sous arbre gauche;
    fin si

En langage C, nous aurons donc :

tree Left( tree T)
{
    if ( IsEmpty(T) )
        return NULL;
    else
        return T->left;
}

La fonction qui retourne le fils droit sera codé de la même manière mais tout simplement au lieu de renvoyer le fils gauche, nous renvoyons le fils droit.

Passons à une autre fonction qui peut nous être utile : savoir si nous sommes sur une feuille. Voici tout d'abord le pseudo code :

fonction EstUneFeuille(T : arbre) renvoie un booléen.
{
    si EstVide(T) alors
        renvoyer faux;
    sinon si EstVide(FilsGauche(T)) et EstVide(FilsDroit(T)) alors
        renvoyer vrai;
    sinon
        renvoyer faux;
    fin si
}

Ceci donne en C :

bool IsLeave(tree T)
{
    if (IsEmpty(T))
        return false;
    else if IsEmpty(Left(T)) && IsEmpty(Right(T))
        return true;
    else
        return false;
}

Enfin, nous pouvons créer une dernière fonction bien que très peu utile : déterminer si un noeud est un noeud interne. Pour ce faire, deux méthodes : soit on effectue le test classique en regardant si un des fils n'est pas vide, soit on utilise la fonction précédente.

Voici le pseudo code utilisant la fonction précédente :

fonction EstNoeudInterne( T : arbre ) renvoie un booléen

    si EstUneFeuille(T) alors
        renvoyer faux;
    sinon
        renvoyer vrai;
    fin si

On aura donc en C la fonction suivante :

bool IsInternalNode(tree T)
{
    return ! IsLeave(T) ;
}

V. Algorithmes de base sur les arbres binaires

Nous présentons les algorithmes de base sur les arbres. Sachez qu'ici c'est le domaine de la récursivité. Si vous êtes réticent à ce genre de fonction, il faut savoir qu'il n'y a pas d'autres alternatives. Mais rassurez vous, nous allons procéder en douceur, les fonctions que nous allons voir ne sont pas compliquée. De plus, le schéma est quasiment le même, une fois que vous aurez vu deux ou trois fois ce schéma, tout ira bien.

V-A. Calcul de la hauteur d'un arbre

Pour calculer la hauteur d'un arbre, nous allons nous baser sur la définition récursive :

- un arbre vide est de hauteur 0
- un arbre non vide a pour hauteur 1 + la hauteur maximale entre ses fils.

De part cette définition, nous pouvons en déduire un algorithme en pseudo code :

fonction hauteur ( T : arbre ) renvoie un entier

    si T est vide
        renvoyer 0
    sinon
        renvoyer 1 + max (hauteur ( FilsGauche(T)) , hauteur(FilsDroit(T) )
    fin si

Ainsi ceci pourrait donner en C :

unsigned Height (tree T)
{
    if ( IsEmpty(T) )
        return 0;
    else
        return 1 + max( Height(Left(T) ) , Height(Right(T) ) );
}

La fonction max n'est pas définie c'est ce que nous faisons maintenant :

unsigned max(unsigned a,unsigned b)
{
    return (a>b)? a : b ;
}

V-B. Calcul du nombre de noeud

Le calcul du nombre de noeud est très simple. On définit le calcul en utilisant la définition récursive :

- Si l'arbre est vide : renvoyer 0
- Sinon renvoyer 1 plus la somme du nombre de noeuds des sous arbres.

On aura donc le pseudo code suivant :

fonction NombreNoeud( T : arbre ) renvoie un entier

    si ( EstVide( T ) )
        renvoyer 0;
    sinon
        renvoyer 1 + NombreNoeud(FilsGauche(T)) + NombreNoeud(FilsDroit(T));
    fin si

On peut donc traduire celà en langage C :

unsigned NbNode(tree T)
{
    if( IsEmpty(T) )
        return 0;
    else
        return 1 + NbNode(Left(T)) + NbNode(Right(T));
}

V-C. Calcul du nombre de feuilles

Le calcul du nombre de feuille repose sur la défintion récursive :

- un arbre vide n'a pas de feuille.
- un arbre non vide a son nombre de feuille défini de la façon suivante :
    - si le noeud est une feuille alors on renvoie 1
    - si c'est un noeud interne alors le nombre de feuille est la somme du
      nombre de feuille de chacun de ses fils.

Voici le pseudo code que nous pouvons en tirer :

fonction nombreFeuille ( T : arbre ) renvoie un entier

    si T est vide alors
        renvoyer 0;
    sinon si T est une feuille alors
        renvoyer 1
    sinon
        renvoyer nombrefeuille( FilsGauche(T) ) + nombrefeuille( FilsDroit(T) );
    fin si

Voici ce que celà peut donner en langage C :

unsigned NbLeaves( tree T)
{
    if( IsEmpty(T) )
        return 0;
    else if ( IsLeave(T) )
        return 1;
    else
        return NbLeaves(Left(T)) + NbLeaves(Right(T));
}

V-D. Nombre de noeud internes

Maintenant, pour finir avec les algorithmes de base, nous allons calculer le nombre de noeud interne, Cela repose sur le même principe que le calcul du nombre de feuille. La définition récursive est la suivante :

- un arbre vide n'a pas de noeud interne.
- si le noeud en cours n'a pas de fils alors renvoyer 0
- si le noeud a au moins un fils, renvoyer 1 plus la somme des noeuds interne des sous arbres.

On en déduit donc aisément le pseudo code correspondant :

fonction NombreNoeudInterne(T : arbre ) renvoie un entier

    si EstVide(T) alors
        renvoyer 0
    sinon si EstUneFeuille(T) alors
        renvoyer 0
    sinon
        renvoyer 1 + NombreNoeudInterne(FilsGauche(T)) +
                NombreNoeudInterne(FilsDroit(T));

Ce qui donne en langage C :

unsigned NbInternalNode(tree T)
{
    if (IsEmpty(T))
        return 0;
    else if(IsLeave(T))
        return 0;
    else
        return 1 + NbInternalNode(Left(T)) + NbInternalNode(Right(T));
}

VI. Parcours d'un arbre

Nous allons découvrir des algorithmes de parcours d'un arbre. Cela permet de visiter tous les noeuds de l'arbre et éventuellement appliquer une fonction sur ces noeud. Nous distinguerons deux types de parcours : le parcours en profondeur et le parcours en largeur. Le parcours en profondeur permet d'explorer l'arbre en explorant jusqu'au bout une branche pour passer à la suivante. Le parcours en largeur permet d'explorer l'arbre niveau par niveau. C'est à dire que l'on va parcourir tous les noeuds du niveau 1 puis ceux du niveau deux et ainsi de suite jusqu'à l'exploration de tous les noeuds.

VI-A. Parcours en profondeur

Le parcours en profondeur de l'arbre suivant donne : 1,2,3,4,5,6,7,8,9,10 :

Parcours prefixe Parcours préfixe de l'arbre : 1-2-3-4-5-6-7-8-9-10

Ceci n'est en fait qu'un seul parcours en profondeur de l'arbre. Il s'agit d'un parcours d'arbre en profondeur à gauche d'abord et préfixe. Précisons. tout de suite ces termes.

Un parcours en profondeur à gauche est simple à comprendre, cela signifie que l'on parcours les branches de gauche avant les branches de droite. On aura dons deux types de parcours : un parcours à gauche et un parcours à droite. Dans la plupart des cas, nous utiliserons un parcours à gauche.

La deuxième caractéristique de notre arbre est le parcours dit préfixe. Celà signifie que l'on affiche la racine de l'arbre, on parcourt tout le sous arbre de gauche, une fois qu'il n'y a plus de sous arbre gauche on parcourt les éléments du sous arbre droit. Ce type de parcours peut être résumé en trois lettres : R G D (pour Racine Gauche Droit). On a aussi deux autres types de parcours : le parcours infixe et le parcours suffixe (appelé aussi postfixe). Le parcours infixe affiche la racine après avoir traité le sous arbre gauche, après traitement de la racine, ontraite le sous arbre droit (c'est donc un parcours G R D). Le parcours postfixe effectue donc le dernier type de schéma : sous arbre gauche, sous arbre droit puis la racine, c'est donc un parcours G D R. Bien sur, nous avons fait l'hypothèse d'un parcours à gauche d'abord mais on aurait pû aussi faire un parcours à droite d'abord.

Les types de parcours infixe,suffixe et postfixe sont les plus importants, en effet chacun à son application particulière. Nous verrons celà dans la suite

Maintenant que nous avons la définition des types de parcours, exprimons ceci en terme de pseudo-code.

Commençons par le parcours préfixe, celui ci traite la racine d'abord.

procedure parcours_prof_prefixe( T : arbre )

    si non EstVide(T) alors

        traiter_racine(T);
        parcours_prof_prefixe(FilsGauche(T));
        parcours_prof_prefixe(FilsDroit(T));

    fin si

La fonction (ou procédure) traiter_racine, est une fonction que vous définissez vous même, il s'agit par exemple d'une fonction d'affichage de l'élément qui est à la racine.

Maintenant le parcours infixe :

procedure parcours_prof_infixe( T : arbre )

    si non EstVide(T) alors

        parcours_prof_infixe(FilsGauche(T));
        traiter_racine(T);
        parcours_prof_infixe(FilsDroit(T));

    fin si

Et enfin le parcours suffixe :

proceure parcours_prof_suffixe(T : arbre )

    si non EstVide(T) alors

        parcours_prof_suffixe(FilsGauche(T));
        parcours_prof_suffixe(FilsDroit(T));
        traiter_racine(T);

    fin si

Voilà pour les trois fonctions de parcours. Vous remarquerez que seul le placement de la fonction (ou procédure) traiter_racine diffère d'une fonction à l'autre. Nous pouvons donc traiter ceci facilement, afin de ne pas à avoir à écrire trois fois de suite le même code. Voici donc le code que l'on peut avoir en C :

void DFS(tree T, char Type)
{
    if( ! IsEmpty(T) )
    {
        if( Type ==1 )
        {
            /* traiter racine */
        }

        DFS(Left(T), Type);

        if (Type == 2)
        {
            /* traiter racine */
        }

        DFS(Right(T), Type);

        if( Type == 3)
        {
            /* traiter racine */
        }
    }
}

Ainsi à partir de ce code, on peut facilement créer trois fonctions qui seront respectivement le parcours préfixe, le parcours infixe et le parcours suffixe.

void DFS_prefix(tree T)
{
    DFS(T,1);
}

void DFS_infix(tree T)
{
    DFS(T,2);
}

void DFS_postfix(tree T)
{
    DFS(T,3);
}

VI-B. Parcours en largeur (ou par niveau)

Nous allons aborder un type de parcours un peu plus compliqué, c'est le parcours en largeur. Il s'agit d'un parcours dans lequel, on traite les noeuds un par un sur un même niveau. On passe ensuite sur le niveau suivant, et ainsi de suite. Le parcours en largeur de l'arbre suivant est : 1 2 3 4 5 6 7 8 9 10.

parcours largeur Parcours en largeur de l'arbre

Une méthode pour réaliser un parcours en largeur consiste à utiliser une structure de données de type file d'attente. Pour ceux qui ne connaissent pas encore ce type de structure de données, il s'agit tout simplement d'une structure de données qui est obéit à la règle suivante : premier entrée, premier sorti.

Pour en revenir à notre parcours, le principe est le suivant, lorsque nous somme sur un noeud nous traitons ce noeud (par exemple nous l'affichons) puis nous mettons les fils gauche et droit non vides de ce noeud dans la file d'attente, puis nous traitons le prochain noeud de la file d'attente.

Au début, la file d'attente ne contient rien, nous y plaçons donc la racine de l'arbre que nous voulons traiter. L'algorithme s'arrête lorsque la file d'attente est vide. En effet, lorsque la file d'attente est vide, cela veut dire qu'aucun des noeuds parcourus précédemment n'avait de sous arbre gauche ni de sous arbre droit. Par conséquent, on a donc bien parcouru tous les noeuds de l'arbre.

On en déduit donc le pseudo code suivant :

Sélectionnez

procedure parcours_largeur(T : arbre)

    Creer_File_D'attente F

    ajouter(F,T);

    tant que F n'est pas vide faire
        X <- extraire(F);
        Traiter_racine(X);

        si non EstVide(FilsGauche(X)) alors
            ajouter(F,FilsGauche(X));
        fin si

        si non EstVide(FilsDroit(X)) alors
            ajouter(F,FilsDroit(X));
        fin si

    fin faire

Appliquons ce pseudo code à l'arbre suivant :

Image non disponible

Au tout début de l'algorithme, la file ne contient rien, on y ajoute donc l'arbre, la file d'attente devient donc :

Image non disponible Etat de la file d'attente

On traite la racine puis on y ajoute les fils droits et gauche, la file vaut donc :

Image non disponible Etat de la file après la première itération

On traite ensuite le prochain élément de la file d'attente. Ce noeud n'a pas de sous arbre, on ajoute donc rien à la file. Celle ci vaut donc :

Image non disponible Etat de la file après la deuxième itération

On traite le prochain noeud dans la file d'attente. Celui ci a un fils droit, nous l'ajoutons donc à la file d'attente. Cette dernière ne contient donc maintenant plus qu'un noeud :

Image non disponible Etat de la file après la troisième itération

On traite ce noeud. Celui ci n'ayant pas de fils, nous n'ajoutons donc rien à la file. La file est désormais vide, l'algorithme se termine.

Nous pouvons écrire cet algorithme en langage C. Cependant, nous n'avons pas vu les files dans cet article, je vous renvoie donc à l'article de Nicolas Joseph sur la création des files d'attentes en C Ici Nous ne rentrerons pas plus en détail sur les files dans la suite. Voici donc le code en C :

void WideSearch(tree T)
{
    tree Temp;
    queue F;

    if( ! IsEmpty(T) )
    {
        Add(F,T);

        while( ! Empty(F) )
        {
            Temp = Extract(F);

            /* Traiter la racine */
            if( ! IsEmpty(Left(Temp)) )
                Add(F,Temp);
            if( ! IsEmpty(Right(Temp)) )
                Add(F,Temp);
        }
    }
}

VII. Opérations élémentaires sur un arbre

Maintenant que nous savons parcourir un arbre, que nous savons obtenir des informations sur un arbre, il serait peut être temps de créer un arbre, de l'alimenter et enfin de supprimer des des élements.

VII-A. Création d'un arbre

On peut distinguer deux types de création d'un arbre : création d'un arbre vide, et création d'un arbre à partir d'un élément et de deux sous arbres. La première méthode est très simple, étant donné que nous avons crée un arbre comme étant un pointeur, un arbre vide est donc un pointeur Null. La fonction de création d'un arbre vide est donc une fonction qui nous renvoie la constante Null.

La deuxième fonction est un peu plus compliquée mais rien de très impressionnant. Il faut tout d'abord créer un noeud, ensuite, on place dans les fils gauche et droit les sous arbres que l'on a passé en paramètre ainsi que la valeur associée au noeud. Enfin, il suffit de renvoyer ce noeud. En fait, ce n'est pas tout à fait exact, puisque ce n'est pas un noeud mais un pointeur sur un noeud qu'il faut renvoyer. Mais nous utilisons le terme noeud pour spécifier qu'il faut allouer un noeud en mémoire.

Voici le pseudo code de la deuxième fonction :

fonction CreerArbre(val : TElement, fg : arbre , fd : arbre ) renvoie un arbre

    X <- Allouer un noeud en mémoire.

    X.valeur <- val;
    X.fils_gauche <- fg;
    X.fils_droit <- fd;

    renvoyer X;

Ceci donnera donc en langage C :

tree Create(TElement val,tree ls, tree rs)
{
    tree res;

    res = malloc(sizeof(*res));

    if( res == NULL )
    {
        fprintf(stderr,"Impossible d'allouer le noeud");
        return NULL;
    }

    res->value = val;
    res->left = ls;
    res->right = rs;

    return res;
}

Notre fonction renvoie NULL s'il a été impossible d'allouer le noeud. Ceci est un choix arbitraire, vous pouvez très bien effectuer d'autres opérations à la place.

VII-B. Ajout d'un élément

L'ajout d'un élément est un peu plus délicat. En effet, il faut distinguer plusieurs cas : soit on insère dès que l'on peut. soit on insère de façon à obtenir un arbre qui se rapproche le plus possible d'un arbre complet, soit on insère de façon à garder une certaine logique dans l'arbre.

Le premier type d'ajout est le plus simple, dès que l'on trouve un noeud qui a un fils vide, on y met le sous arbre que l'on veut insérer. Cependant ce type de technique, si elle sert à construire un arbre depuis le début à un inconvénient : on va créer des arbres du type peigne. C'est à dire que l'on va insérer notre élément tel que l'arbre final ressemblera à ceci :

Ceci est très embêtant dans la mesure ou celà va créer des arbres de très grande hauteur, et donc très peu performant. Néanmoins, il peut arriver des cas où on a parfois besoin de ce type d'insertion. Dans ce cas, l'insertion se déroule en deux temps : on cherche d'abord un fils vide, puis on insère. Ceci peut s'écrire récursivement :

- si l'arbre dans lequel on veut insérer notre élément est vide,
    alors il s'agit d'une création d'arbre.
- sinon, si le noeud en cours a un fils vide, alors on insère dans le fils vide.
       -sinon, on insère dans le fils gauche.

Vous remarquerez que l'on insère du coté gauche, ceci aura pour effet de produire un peigne gauche, et si on insérait du coté droit, nous aurions un peigne droit.

Nous pouvons donc écrire le pseudo code correspondant :

procedure AjouteNoeud( src : arbre, elt : TElement )

    si EstVide(src) alors
        src <- CreerArbre(elt,Null,Null);
    sinon
        si EstVide(FilsGauche(src)) alors
            src->gauche <- CreerArbre(elt,Null,Null);
        sinon
            si EstVide(FilsDroit(src)) alors
                src->droit <- CreerArbre(elt,Null,Null);
            sinon
                AjouteNoeud(FilsGauche(src),elt);
            fin si
        fin si
    fin si

On peut traduire ceci en C :

void AddElt(tree src, TElement elt)
{
    if ( src == NULL )
    {
        src = Create(elt,NULL,NULL);
    }
    else if ( IsEmpty(Left(src)) )
    {
        src->left = Create(elt,NULL,NULL);
    }
    else if ( IsEmpty(Right(src)) )
    {
        src->right = Create(elt,NULL,NULL);
    }
    else
    {
        AddElt(Left(src),elt);
    }
}

On remarque donc qu'ici nous créons des arbres qui ne sont pas perfomant (nous verrons ceci dans la recherche d'élément). Afin d'améliorer ceci, on peut éventuellement effectuer notre appel récursif soit à gauche soit à droite et ceci de façon aléatoire. Ceci permet un équilibrage des insertions des noeuds mais ce n'est pas parfait.

La solution pour obtenir des arbres les plus équilibrés possible consiste en l'utilisation d'arbres binaires dit rouge-noir. Ceci sont assez compliqué et nous ne les verrons pas. Cependant, nous allons voir un type d'arbre qui facilite les recherche : les arbres binaires de recherche.

Dans ce type d'arbre, il y a une cohérence entre les noeuds, c'est à dire que la hierarchie des noeuds respecte une règle. Celle ci est simple. Pour un arbre binaire de recherche contenant des entiers, nous considérerons que les valeurs des noeuds des sous arbres gauche sont inférieur à la racine de ce noeud et les valeurs des sous arbres droit sont suppérieurs à cette racine. Voici un exemple d'un tel arbre :

Arbre recherche Exemple d'un arbre binaire de recherche

Puisque nous sommes dans la partie ajout de noeud, voyons comment ajouter un noeud dans un tel arbre :

procedure InsereArbreRecherche( src : arbre , elt : TElement )

    si EstVide(src) alors
        src <- CreerArbre(elt,Null,Null);
    sinon
        si elt < src->val alors
            InsereArbreRecherche(FilsGauche(src), elt);
        sinon
            InsereArbreRecherche(FilsDroit(src), elt);
        fin si
    fin si

Le principe d'insertion est simple : si on a un arbre vide, alors il s'agit de la création d'un arbre. Sinon, on compare la valeur de l'élément à insérer avec la valeur de la racine. Si l'élément est plus petit alors on insère à gauche. sinon, on insère dans le fils droit de la racine.

Voici le code en C que l'on peut écrire pour insérer un élement dans un arbre binaire de recherche.

void insertSearchTree(tree src, TElement elt)
{
    if( IsEmpty(src) )
    {
        src = Create(elt,NULL,NULL);
    }
    else
    {
        if (elt < src->value)
        {
            insertSearchTree(Left(src), elt);
        }
        else
        {
            insertSearchTree(Right(src), elt);
        }
    }
}

Vous remarquerez que l'on insère les éléments qui sont égaux à la racine du coté droit de l'arbre.

Autre remarque, vous constaterez que nous effectuons des comparaisons sur les entités du type TElement, ceci n'est valable que pour des valeurs numériques (entier, flottant et caractère). S'il s'agit d'autres types, vous devrez utiliser votre propre fonction de comparaison. (comme strcmp pour les chaînes de caractère).

VII-C. Recherche dans un arbre

Après avoir alimenté notre arbre, il serait peut être temps d'effectuer des recherches sur notre arbre. Il ya principalement deux méthodes de recherche. Elles sont directement liées au type de l'arbre : si l'arbre est quelconque et si l'arbre est un arbre de recherche.

Nos recherches se contenterons seulement de déterminer si la valeur existe dans l'arbre. Avec une petite adaptation, on peut récupérer l'arbre dont la racine contient est identique à l'élément cherché.

Commençons par chercher l'élement dans un arbre quelconque. Cette méthode est la plus intuitive : on cherche dans tous les noeuds de l'arbre l'élement. Si celui ci est trouvé, on renvoie vrai, si ce n'est pas le cas, on renvoie faux. Voici le pseudo code associé.

fonction Existe(src : arbre, elt : TElement) renvoie un booléen

    si EstVide(src)
        renvoyer faux
    sinon
        si src->value = elt alors
            renvoyer vrai
        sinon
            renvoyer Existe(FilsGauche(src),elt ) ou Existe(FilsDroit(src), elt);
        fin si
    sin si

Nous renvoyons un ou logique entre le sous arbre gauche et le sous arbre droit, pour pouvoir renvoyer vrai si l'élement existe dans l'un des sous arbres et faux sinon.

Ce genre de recherche est correcte mais n'est pas très performante. En effet, il faut parcourir quasiment tous les noeuds de l'arbre pour déterminer si l'élément existe.

C'est pour celà que sont apparus les arbres binaires de recherche. En effet, on les nomme ainsi parce qu'ils optimisent les recherches dans un arbre. Pour savoir si un élément existe, il faut parcourir seulement une branche de l'arbre. Ce qui fait que le temps de recherche est directement proportionnel à la hauteur de l'arbre.

L'algorithme se base directement sur les propriétés de l'arbre, si l'élement que l'on cherche est plus petit que la valeur du noeud alors on cherche dans le sous arbre de gauche, sinon, on cherche dans le sous arbre de droite. Voici le pseudo code correspondant :

fonction ExisteR( src : arbre, elt : TElement )

    si EstVide(src) alors
        renvoyer faux
    sinon
        si src->valeur = elt alors
            renvoyer vrai;
        sinon
            si src->valeur > elt alors
                renvoyer ExisteR(FilsGauche(src) , elt );
            sinon
                renvoyer ExisteR(FilsDroit(src) , elt );
            fin si
        fin si
    fin si

Ce qui se traduit de la façon suivante en langage C :

bool Exist(tree src , TElement elt)
{
    if ( IsEmpty(src) )
        return false;
    else if ( src->value == elt )
        return true;
    else if ( src->value > elt )
        return Exist(Left(src), elt);
    else
        return Exist(Right(src), elt);
}

VII-D. Suppression d'un arbre

Nous allons terminé cet article par la suppression d'éléments d'un arbre, ou plutôt la destruction complète d'un arbre.

Par suppression de noeud, nous entendrons suppression d'une feuille. En effet, un noeud qui possède des fils s'il est supprimé, entraîne une réorganisation de l'arbre. Que faire alors des sous arbres du noeud que nous vollons supprimer ? La réponse à cette question dépend énormément du type d'application de l'arbre. On peut les supprimer, on peut réorganiser l'arbre (si c'est un arbre de recherche) en y insérant les sous arbres qui sont devenus orphelins. Bref, pour simplifier les choses, nous allons nous contenter de supprimer completement l'arbre.

L'algorithme de suppression de l'arbre est simple : on supprime les feuilles une par unes. On répète l'opération autant de fois qu'il y a de feuilles. Cette opération est donc très dépendante du nombre de noeud.

En fait cet algorithme est un simple parcours d'arbre. En effet, lorsque nous devrons traiter la racine, nous appelerons une fonction de suppression de la racine. Comme nous avons dis plutôt que nous ne supprimerons que des feuilles, avant de supprimer la racine, il faut supprimer les sous arbres gauche et droit. On en déduit donc que l'algorithme de suppression est un parcours d'arbre postfixe.

Voici le pseudo code associé :

procedure supprime( src : arbre )

    si non EstVide(src) alors
        supprime(FilsGauche(src));
        supprime(FilsDroit(src));
        supprimeNoeud(src);
    fin si

Le pseudo code est donc très simple. De la même manière, on en déduit le code en C :

void Erase(tree * src)
{
    tree ls = Left(*src);
    tree rs = Right(*src);

    if( ! IsEmpty(*src) )
    {
        Erase( &ls );
        Erase( &rs );

        free( *src );
        *src = NULL;
    }
}

On peut se demander pourquoi nous passons par un pointeur sur un arbre pour effectuer notre fonction. Ceci est tout simplement du au fait que le C ne fait que du passage de paramètre par copie et par conséquent si on veut que l'arbre soit réelement vide (ie : qu'il soit égal à NULL) après l'éxécution de la fonction, il faut procéder ainsi.

Les appels à Left et Right au début ne posent aucun problème dans le cas où src vaut NULL. En effet, dans ce cas, les deux fonctions renverrons le pointeur NULL.


Les pièges du C

Table des matières

  • I. Introduction
  • II. Dépassement de tampon
  • III. Format de chaîne de caractères
  • IV. Dépassement de capacité d'entier
  • V. Conclusion
  • VI. Remerciements

Dans cet article, je vous expose quelques pièges à éviter lorsque l'on développe en C.

I. Introduction

Le C est un langage proche de la machine (manipulation d'adresses mémoire) et le compilateur laisse une grande liberté au développeur. De ce fait, écrire un programme en C demande beaucoup de rigueur. Cependant écrire un code propre et sans comportement indéfini n'est pas suffisant. En effet, l'intérêt pour l'informatique serait bien moindre sans l'interactivité homme-machine. Hélas s'il y a un point sur lequel on ne peut faire aucune supposition c'est bien la réponse que va fournir un utilisateur à notre programme. Dans un monde idéal où tous les utilisateurs seraient voués de bonnes intentions, une mauvaise réponse entraînerait dans le pire des cas, un arrêt brutal du programme et une perte des données non enregistrées. Mais comme nous ne sommes pas dans un monde parfait, il existe des utilisateurs mal intentionnés et les conséquences peuvent être bien plus graves qu'un simple plantage. Et bien souvent le but recherché par les pirates est d'obtenir les pleins droits sur une machine (un shell avec les droits root sur un système de type Linux par exemple) ou encore lancer l'exécution d'un virus. Maintenant que j'ai essayé de vous convaincre de la dangerosité d'un programme possédant des failles, je vais vous décrire les types de failles les plus connus et surtout la manière de les éviter. Il est peut être un peut tard mais je m'excuse auprès des personnes qui lisent cet article dans le but de pirater un ordinateur, en effet il n'y a aucune explication à ce sujet et histoire de vous encourager à allez voir de quoi il s'agit, sachez que pour espérer mettre ceci en pratique, il faut avoir quelques connaissances en C (audit de code), en assembleurs et en conceptions des systèmes d'exploitation : bon courage ;)

II. Dépassement de tampon

Aussi appelé buffer overflow, il s'agit sûrement du type de faille le plus connu et le plus simple à éviter. Lors d'un dépassement de tampon, l'utilisateur entre une chaîne de caractères qui va se retrouver dans un tableau de caractères de taille insuffisante ce qui va entraîner l'écriture de données en dehors de la zone mémoire allouée pour le tableau. Suivant le type de tableau (statique ou dynamique), on distingue deux types de buffer overflow :

  • Stack overflow : dépassement de tampon qui va écraser la pile (tableau en mémoire statique)
  • Heap overflow : dépassement de tampon qui va écraser le tas (tableau dynamique)

II-A. Sur la pile

Pour commencer voici un petit code qui illustre le problème :

#include <stdio.h>
#include <string.h>

static void cpy_arg (const char *);

int main (int argc, char **argv)
{
   if (argc == 2)
   {
      cpy_arg (argv[1]);
      printf ("argv[1] copier\n");
   }
   return 0;
}

static void cpy_arg (const char *s)
{
   if (s)
   {
      char text[20];

      strcpy (text, s);
   }
}

Les habitués auront tout de suite repéré que si l'utilisateur fourni comme argument à notre programme une chaîne de plus de 19 caractères, on obtient un dépassement de capacité lors de la copie dans text. Si le dépassement est suffisamment important, il est possible d'écraser des données système stockées dans la pile. Cette zone de mémoire doit son nom à la manière dont elle est utilisée : les données sont empilées lors de l'appel d'une fonction puis dépilée dans l'ordre inverse au retour dans la fonction appelante.
Les données stockées dans la pile sont :

  • Les arguments de la fonction appelée
  • Ses variables locales
  • La valeurs des registres processeurs qui vont permettre, lors du retour de la fonction, de retrouver le contexte d'exécution de la fonction appelante

Ce sont ces registres qui vont nous intéresser puisque l'un d'entre eux est destiné à stocker l'adresse de la prochaine instruction à exécuter. Maintenant imaginez que dans notre fonction cpy_arg, on modifie cette valeur, au retour dans le main, le programme, à la place d'exécuter l'instruction printf, va chercher une instruction à l'adresse modifiée, qui peut pointer sur une zone mémoire où un pirate aura préalablement stocké un morceau de code de son cru (appelé shellcode). Ceci peut être facilement démontré :

$ gcc -Wall -W -O2 -ansi -pedantic bof.c
$ gdb a.out
...
(gdb) set args `perl -e "{print 'A'x512}"`
(gdb) run
Starting program: ./a.out `perl -e "{print 'A'x512}"`

Program received signal SIGSEGV, Segmentation fault.
0x41414141 in ?? ()
(gdb)

Notre programme plante car il a essayé d'exécuter une instruction à l'adresse 0x414141 (0x41 est le code ASCII du caractère 'A'). On pourrait minimiser les risques d'une telle faille en se disant qu'il faut pouvoir entrer assez de données pour atteindre la pile. Hélas un débordement d'un byte peut permettre d'arriver au même résultat, on parle alors de off-by-one overflow (dans ce cas, plutôt que de modifier la valeur d'un registre placé dans la pile, on modifie la valeur d'une variable locale).

Pour conclure cette première partie, je vous propose un code que je trouve assez amusant et qui montre les véritables dangers d'une telle faille :

#include <stdio.h>
#include <string.h>

static void cpy_arg (const char *);
void code_pirate (void);

int main (int argc, char **argv)
{
   if (argc == 2)
   {
     printf ("code_pirate = %p\n", code_pirate);
     cpy_arg (argv[1]);
     printf ("argv[1] copier\n");
   }
   return 0;
}

static void cpy_arg (const char *s)
{
   if (s)
   {
      char text[20];

      strcpy (text, s);
   }
}

void code_pirate (void)
{
  printf ("Code pirate execute !\n");
}

On l'exécute :

$ a.out aaaabbbbccccddddeeeeffffgggghhhhiiiijjjjkkkk\x23\x13\x40
code_pirate = 00401323
Code pirate execute !
Segmentation fault

Et oh stupeur, la fonction code_pirate qui n'est jamais appelée dans notre code se retrouve exécutée ! Tout simplement parce qu'à la place d'écraser la valeur de la prochaine instruction à exécuter par une valeur quelconque (0x414141), on la remplace par l'adresse de la fonction code_pirate (little endian oblige, les octets sont entrés à l'envers).
Maintenant imaginez que l'adresse entrée n'est pas celle d'une fonction de notre programme mais celle d'une fonction présente dans une bibliothèque à liaison dynamique !
Dans ce cas d'école, le programme plante mais il est possible de le faire se terminer normalement : l'exécution du code est alors transparente pour l'utilisateur.

II-B. Sur le tas

Dans notre cas précédent, nous avons travaillé sur un tableau alloué en mémoire statique. Comme il existe des tableaux alloués en mémoire dynamique (fonction malloc, calloc et realloc) il existe aussi un dépassement de tampon qui va avec : le heap overflow. Cette fois le principe est un peu plus compliqué puisqu'il s'agit de modifier le contenu des structures de données utilisées par le système lors d'une allocation/désallocation de mémoire pour y stocker le code malicieux.

III. Format de chaîne de caractères

Qu'est ce qu'un format de chaîne de caractères ? Il s'agit du premier argument de la fonction printf, qui permet de spécifier le format de la chaîne de caractère à afficher (ce paramètre est présent chez toutes les fonctions de la famille de printf). Comme à chaque fois, le problème survient lorsque l'utilisateur a le contrôle sur le contenu de cette chaîne :

#include <stdio.h>

int main (int argc, char **argv)
{
   if (argc == 2)
   {
      char text[256];

      sprintf (text, "%s\n", argv[1]);
      printf (text);
   }
   return 0;
}

Le spécificateur de format %x permet d'afficher une valeur en hexadécimale :

Sélectionnez
$ ./a.out "%x"
8048524

En l'absence de paramètre, la fonction printf va afficher les valeurs présentes dans sa pile d'appel. Voici un exemple plus parlant :

Sélectionnez
$ ./a.out "AAAA|%x|%x|%x|%x|"
AAAAA|8048524|bffc1ac3|1|41414141|

Au bout d'un moment, on retrouve le début de notre chaîne! Tout cela n'aurait pas de conséquences désagréables s'il n'était pas possible d'écrire en mémoire. Hélas la fonction printf propose l'option %n qui permet de sauvegarder le nombre de caractère déjà écrit :

Sélectionnez
#include <stdio.h>

int main (void)
{
   int i = 0;

   printf ("Hello World!\n%n", &i);
   printf ("Cette phrase contient %d caracteres\n", i);
   return 0;
}

Ce qui donne :

$ ./a.out
Hello World!
Cette phrase contient 13 caracteres

On a bien écrit la valeur 13 à l'adresse &i. Eh bien essayons :

$ gdb a.out
...
(gdb) set args "AAAA|%x|%x|%x|%x|%n"
(gdb) run
Starting program: ./a.out "AAAA|%x|%x|%x|%x|%n"

Program received signal SIGSEGV, Segmentation fault.
0xb7e4b8a3 in vfprintf () from /lib/tls/libc.so.6

IV. Dépassement de capacité d'entier

Dernière vulnérabilité et sûrement la plus difficile à détecter. En effet cette faille utilise le fait que les nombres ne peuvent dépasser une certaine valeur qui dépend du nombre de bits utilisés pour leur représentation et de leur méthode de codage. Par exemple pour un unisgned int codé sur 32 bits en représentation binaire pur, la variable peut prendre une valeur allant de 0 à 4 294 967 295. La bibliothèque standard du C permet de retrouver ces valeurs grâce aux fichiers d'en-tête limits.h :

#include <stdio.h>
#include <limits.h>

int main (void)
{
   unsigned int ul = UINT_MAX;
   printf ("ul=%u\n", ul);
   return 0;
}

Ce qui donne sur ma machine :

ul=4294967295

Mais que se passe-t-il si l'on dépasse cette valeur? Voici un exemple :

#include <stdio.h>

int main (void)
{
   unsigned int i = 1;

   while (i > 0)
   {
      i++;
   }
   printf("Hello world!\n");
   return 0;
}

D'un point de vu algorithmique, notre programme reste indéfiniment dans la boucle while mais si vous exécutez ce code suffisamment longtemps vous devriez voir s'afficher 'Hello world!'. Cela signifie que la valeur de i est devenue négative!
Pour vous montrer la difficulté de détecter ce genre de faille, voici un code vulnérable :

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main (int argc, char **argv)
{
    int ret = EXIT_FAILURE;

    if (argc == 2)
    {
        char buf[255];
        unsigned char size = strlen (argv[1]);

        if (size < 254)
        {
            printf ("size = %d\n", size);
            strcpy (buf, argv[1]);
            ret = EXIT_SUCCESS;
        }
    }
    return ret;
}

Après l'avoir compilé avec le mode parano de gcc :

$ ./a.out `perl -e "{print 'A'x500}"`
size = 244
Erreur de segmentation

Eh oui, la valeur 500 stockée dans un unsigned char (codé sur 1 octet sur ma machine), se transforme en 244 et nous permet de copier 500 caractères dans une tableau prévu pour en contenir que 255 et l'on se retrouve dans le cas classique d'un !

V. Conclusion

Comme vous avez pu le constater, bien configurer son compilateur ne suffit pas pour obtenir du code sûr. Il faut aussi privilégier les fonctions qui tiennent compte de la taille de leurs arguments, telles que fgets, strncpy ou encore strncat pour éviter tout débordement. Cependant pour ces deux dernières, il ne s'agit pas d'une solution idéale puisque que si la taille de la chaîne dépasse celle spécifiée en paramètre, le 0 de fin de chaîne n'est pas ajouté (il suffit de le faire manuellement, encore faut-il y penser).
Il semble tout de même utopique de n'exécuter que des programmes dont le code est sans faille. Par conséquent, plutôt que passer en revue tous les codes (à supposer que le code soit disponible) pour le sécuriser afin d'éviter l'exécution de code malveillant, il faut penser à une autre solution : sécuriser l'environnement d'exécution des programmes.
Par exemple sous Linux, il existe un ensemble de patch pour le noyau : GrSecurity qui contient, entre autres, le patch PaX (Protection against eXecution) qui limite les conséquences des failles vu dans cette article. Bien sûr ce genre de protection n'est pas une excuse pour laisser des failles dans vos programmes puisqu'elle n'est pas invulnérable mais permet de rendre la tâche du pirate (beaucoup) plus compliquée.

Comme je l'ai précisé en introduction, je n'ai abordé que les failles les plus connues (de moi surtout) donc si vous en connaissez d'autres, je les intégrerai à cet article avec plaisir ;) Pour me contacter, vous trouverez un lien pour m'envoyer un MP sur la page de l'équipe C & C++.


Récursivité en Langage C

Table des matières

  • I. Introduction
  • II. Fonctions récursives
  • III. Fonctions récursives terminales
  • IV. Dangers et précautions
  • V. Code source complet avec exemple

Etude des fonctions récursives en Langage C.

I. Introduction

La notion de récursivité est avant tout un problème algorithmique plus qu'au niveau du langage lui même. Que ce soit en C, C++, Java, VB, Python, etc.., l'implémentation d'une fonction récursive se fera toujours plus ou moins de la même manière. Ici nous allons traiter de la récursivité avec le Langage C, telle est notre rubrique !

Mais qu'est-ce que la récursivité ? Et bien en fait d'un point de vue théorique cela reste assez simple ; il s'agit de programmes ou de fonctions d'un programme qui ont la faculté de s'appeler eux-mêmes (on entend également le terme d'auto-appel ce qui est logique). La récursivité est une manière simple et élégante de résoudre certains problèmes algorithmiques, notamment en mathématique mais cela ne s'improvise pas, il convient donc de savoir comment ce principe fonctionne.

Nous allons voir deux types de fonctions récursives: les fonctions récursives et les fonctions récursives terminales.

II. Fonctions récursives

Les fonctions récursives comme cité plus haut, sont donc des fonctions s'appelant elles-mêmes, elles sont également un moyen rapide pour poser certains problèmes algorithmique ; nous allons voir en détails comment elles fonctionnent.

Prenons un problème simple mais auquel vous n'avez peut-être pas pensé à utiliser la récursivité: le calcul d'une factorielle. Considérons n! (qui se lit: factorielle de n) comme étant la factorielle à calculer, nous aurons ceci: 6! = 6x5x4x3x2x1. Dans cette situation, nous pouvons déjà déterminer notre règle de sortie de notre fonction récursive: la valeur 1 qui symbolise la fin de la récursion !

En effet, il faut une condition de sortie pour la fonction, mais il faut être très vigilant quant au choix de la condition, vous devrez être sûr qu'elle soit validée à un moment ou à un autre sinon c'est comme si vous créez une boucle infinie sans condition de sortie !

La règle de récursion que nous devons définir, est le calcul de la factorielle en elle même soit, si nous considérons notre exemple sur 6!, cité précédemment, nous pouvons définir notre règle de cette manière: n! = (n) (n-1) (n-2) ... (1). Nous pouvons en déduire que nous allons faire des appels en décrémentant la valeur de n à chaque appel de la fonction jusqu'à ce que n == 1 !

Vous me direz surement, quoi des maths alors que nous parlons de développement en Langage C ! Hé oui, comme cité plus haut c'est avant tout un problème algorithmique et notre exemple est un exemple mathématique comme c'est le cas pour beaucoup d'algorithmes tout de même mais je vous rassure, nous n'irons pas plus loin que ca en maths, c'est tout , nous avons ce qu'il nous faut pour créer notre fonction récursive, la voici:

Fonction récursive simple
Sélectionnez
unsigned long factoriel (int n)
{
   if (n < 0) {
      exit (EXIT_FAILURE);
   }
   else if (n == 1 || n == 0) {
      return 1L;
   }

   return n * factoriel (n - 1);
}

Nous pouvons observer ici que le dernier return est en fait l'appel récursif et nous soustrayons 1 à chaque appel jusqu'à ce que n == 1 qui est, comme décrit plus haut, notre condition de sortie.

Non, cela ne s'arrête pas là et c'est ici que nous allons voir le fonctionnement des fonctions récursives. Lorsque la fonction rencontre la condition de sortie, elle remonte dans tous les appels précédents pour calculer n avec la valeur précédemment trouvée !

Les appels des fonctions récursives sont en fait empilées (pile qui est une structure de donnée régie selon le mode LIFO: Last In First Out, Dernier Entré Premier Sorti). Chaque appel se trouve donc l'un à la suite de l'autre dans la pile du programme. Une fonction de ce type possède donc deux parcours: la phase de descente et la phase de remontée.

Voyons ceci grâce à un petit schéma:

Image non disponible

Nous voyons très bien la phase de descente et de remontée dans la pile des appels de la fonction récursive. Ce n'est qu'au moment de la remontée, donc également au moment où la condition de sortie est vraie, que les appels enregistrés sont dépilés au fur et à mesure de la remontée. Ici pour des petits calculs cela convient très bien mais lorsqu'il s'agit de faire de plus profondes récursions, un autre type de fonction récursive existe mais n'est pas forcément connue de tout le monde, c'est la récursivité terminale, que nous allons étudier dans le prochain chapitre.

III. Fonctions récursives terminales

Avec ce que nous avons vus plus haut, imaginez un instant que vous devez calculez le factoriel d'un très grand nombre, comme par exemple, un calcul de probabilité sur le tirage des boules de Loto ! Oui, si vous connaissez les calculs de probabilités voir même les combinatoires, vous savez que les calculs de factoriels y sont omniprésents mais, je ne vais pas trop entrer dans les détails pour ceux qui ne sont pas très familiers avec ce genre de connaissances mathématiques.

Nous allons simplement imaginer les inconvénients qu'une récursivité normale peut avoir sur des récursions plus profondes. Imaginez une grille de Loto, elle contient 49 numéros dont seulement 6 peuvent êtres tirés au sort. En restant dans un calcul simple pour ne pas trop nous dérouter du sujet, nous aurions une formule comme ceci pour calculer des probabilités:

Image non disponible

Ici, nous devrions calculer le factoriel de 49 ce qui nous donne: 49! = 49x48x47 ... 8x7x6! ; le résultat obtenu est d'une grandeur inimaginable !

Mais cela n'est pas le sujet, c'est juste pour vous montrer que de cette manière, avec une récursivité normale, nous avons (49-6)x2 passages soit 43 appels empilés l'un après l'autre sans compter qu'il faut également remonter tous les appels ce qui nous fait un total de 86 passages. Vous pouvez vous imaginer la perte de temps sur des récursions encore plus profondes et qui risqueraient par ailleurs de faire exploser la pile ce qui conduirait irrémédiablement au plantage du programme !

C'est là que peuvent intervenir les récursions terminales ! Mais c'est quoi ? Me direz-vous ! Et bien c'est une récursion avec uniquement une phase de descente, sans remontée. Ceci est possible car la dernière expression return factoriel_terminale (...) de notre fonction nous renvoie directement la valeur obtenue par l'appel récursif courant, sans qu'il n'y ait d'autres opérations à faire, ce qui n'est pas le cas dans notre fonction récursive simple, où l'on multiplie n par le retour de la fonction. En conséquence, les appels de la fonction n'on pas besoin d'êtres empilés car l'appel suivant remplace simplement l'appel précédent dans le contexte d'exécution.

En réalité, il n'y a aucune garantie que le compilateur puisse optimiser le code pour avoir une récursivité terminale.

En considérant le même calcul que dans le chapitre précédent, soit calculer 6!, voici l'implémentation de notre fonction récursive terminale:

Fonction récursive terminale
Sélectionnez
unsigned long factoriel_terminal (int n, unsigned long result)
{
   if (n < 0) {
      exit (EXIT_FAILURE);
   }

   if (n == 1) {
      return result;
   }
   else if (n == 0) {
      return 1L;
   }

   return factoriel_terminal (n - 1, n * result);
}

Nous remarquons ici que nous avons pris un argument supplémentaire, ceci est un passage obligatoire pour créer une récursivité terminale ; de cette manière, la dernière instruction est belle et bien la fin de l'appel courant de notre fonction et donc l'appel suivant peut prendre la place de la précédente car le résultat se trouve dans notre second argument.

Comme à l'accoutumée, observons un petit schéma pour mieux nous représenter le parcours de la fonction:

Image non disponible

Le schéma nous prouve bel et bien qu'il n'y a qu'une phase de descente mais pas de remontée, De cette manière nous économisons l'utilisation de la pile du programme, et nous gagnons également du temps en exécution, c'est prodigieux !

IV. Dangers et précautions

Les fonctions récursives étant un moyen assez puissant pour résoudre certains problèmes de façon élégante, elles n'en restent pas moins dangereuses et ce pour plusieurs raisons.

IV-A. Dépassement de capacité

Une des causes assez fréquente quand vous travaillez sur de très grands nombres, est le dépassement de capacité. C'est un phénomène qui se produit lorsque vous essayez de stocker un nombre plus grand que ce que peut contenir le type de votre variable.

Il est d'usage de choisir un type approprié, même si vous êtes certains que le type que vous avez choisi ne sera jamais dépassé, utilisez tant que possible une variable pouvant contenir de plus grandes données. Ceci s'applique à tous types de données.

Evitez le type int si vous travaillez avec une fonction récursive, comme les exemples précédents pour le calcul de factoriels. Ce type est très petit et dans une fonction récursive il peut très vite arriver de le dépasser et c'est donc le plantage assuré.

Préférez-lui un type comme long pour assurer un minimum la viabilité de votre application !

Il faut noter que la taille d'un int peut être différente suivant les implémentations systèmes. En effet, par exemple en 32 bits avec un compilateur Microsoft (c), la taille d'un int est la même que celle d'un long, il est donc préférable de se renseigner sur la taille des variables suivant votre système !

IV-B. Débordement de pile (Stack Overflow)

Ceci est sans doute une des causes les plus souvent rencontrés dans le plantage de programmes avec des fonctions récursives. Nous savons que les appels récursifs de fonctions sont placés dans la pile du programme, pile qui est d'une taille assez limité car elle est fixée une fois pour toutes lors de la compilation du programme.

Dans la pile sont non seulement stockés les valeurs des variables de retour mais aussi les adresses des fonctions entre autres choses, les données sont nombreuses et un débordement de la pile peut très vite arriver ce qui provoque sans conteste une sortie anormale du programme.

Dans l'exemple de la fonction factoriel, il nous faut (en arrondissant) environ 135000 appels récursifs pour faire exploser la pile. Vous me direz, c'est déjà pas mal mais je ne jure de rien quand il s'agit d'applications scientifiques !

Une autre méthode existe cependant ! Si vous êtes presque sûr de dépasser ce genre de limites, préférez alors une approche itérative plutôt qu'une approche récursive du problème. Une approche récursive demande beaucoup de moyens en ressources car énormément de données doivent êtres stockées dans la pile d'exécution alors qu'en revanche, une approche itérative telle une boucle for est bien moins coûteuse en terme de ressources et est bien plus sûre, sauf dans le cas d'un dépassement de capacité bien sûr !

IV-C. Forme itérative

Voyons en vitesse une possibilité de forme itérative pour notre calcul de 6! :

Forme itérative
Sélectionnez
unsigned long factoriel_iterative (unsigned int n)
{
   unsigned long ret = 1;
   unsigned int i = 1;

   for (i = 1; i <= n; i++)
   {
      ret *= i;
   }

   return ret;
}

Vous voyez que cela reste simple mais bien sûr, cette forme est un peu moins lisible qu'une approche récursive. L'avantage ici, réside dans le fait que vous ne risquez pas de débordement de pile mais très certainement un dépassement de capacité qui dans ce cas fait également terminer le programme en retournant le signal SIGFPE qui est une constante standard qui indique diverses opérations mathématiques incorrectes telle qu'une division par zéro ou dans notre cas un dépassement de capacité !

V. Code source complet avec exemple

Ici, vous pouvez télécharger le code source complet avec le programme d'exemple et un projet Code::Blocks version Windows: recursivite.zip

Sélectionnez
#include <stdio.h>
#include <stdlib.h>

/*
 * Fonction recursive simple.
 */
unsigned long factoriel (int n)
{
   if (n < 0) {
      exit (EXIT_FAILURE);
   }
   else if (n == 1 || n == 0) {
      return 1L;
   }

   return n * factoriel (n - 1);
}

/*
 * Fonction recursive terminale.
 */
unsigned long factoriel_terminal (int n, unsigned long result)
{
   if (n < 0) {
      exit (EXIT_FAILURE);
   }

   if (n == 1) {
      return result;
   }
   else if (n == 0) {
      return 1L;
   }

   return factoriel_terminal (n - 1, n * result);
}

/*
 * Fonction iterative.
 */
unsigned long factoriel_iterative (unsigned int n)
{
   unsigned long ret = 1;
   unsigned int i = 1;

   for (i = 1; i <= n; i++)
   {
      ret *= i;
   }

   return ret;
}

int main (void)
{
   unsigned long ret = 0;

   /*
    * Recursivite simple.
    */
   ret = factoriel (6);
   printf ("6! = %ld\n", ret);

   /*
    * Recursivite terminale.
    */
   ret = 0;
   ret = factoriel_terminal (6, 1);
   printf ("6! = %ld\n", ret);

   /*
    * Fonction iterative.
    */
   ret = 0;
   ret = factoriel_iterative (6);
   printf ("6! = %ld\n", ret);

   return EXIT_SUCCESS;
}


melem.developpez.com :: Manipulation des fichiers en C

Manipulation des fichiers en C

Table des matières>

  • I. Généralités
  • II. Les erreurs d'E/S
  • III. Positionnement dans un fichier
  • IV. Le traitement par blocs
  • V. Opérations sur les fichiers
  • VI. Les fichiers temporaires
  • VI. Rediriger un flux d'E/S

I. Généralités

I-A. La notion de flux

Les entrées/sorties (E/S) ne font pas partie du langage C car ces opérations sont dépendantes du système. Néanmoins puisqu'il s'agit de tâches habituelles, sa bibliothèque standard est fournie avec des fonctions permettant de réaliser ces opérations de manière portable. Ces fonctions sont principalement déclarées dans le fichier stdio.h. Certaines ont déjà été présentées dans les tutoriels précédents (notamment ici), de même que quelques concepts relatifs aux entrées/sorties en langage C. Aucun rappel ne sera fait, sauf sur certains concepts jugés importants.

Les entrées/sorties en langage C se font par l'intermédiaire d'entités logiques, appelés flux, qui représentent des objets externes au programme, appelés fichiers. En langage C, un fichier n'est donc pas nécessairement un fichier sur disque (ou périphérique de stockage pour être plus précis). Un fichier désigne en fait aussi bien un fichier sur disque (évidemment) qu'un périphérique physique ou un tube par exemple. Selon la manière dont on veut réaliser les opérations d'E/S sur le fichier, qui se font à travers un flux, on distingue deux grandes catégories de flux à savoir les flux de texte et les flux binaires.

Les flux de texte sont parfaits pour manipuler des données présentées sous forme de texte. Un flux de texte est organisé en lignes. En langage C, une ligne est une suite de caractères terminée par le caractère de fin de ligne (inclus) : '\n'. Malheureusement, ce n'est pas forcément le cas pour le système sous-jacent. Sous Windows par exemple, la marque de fin de ligne est par défaut la combinaison de deux caractères : CR (Carriage Return) et LF (Line Feed) soit '\r' et '\n' (notez bien que c'est CR/LF c'est-à-dire CR suivi de LF pas LF suivi de CR). Sous UNIX, c'est tout simplement '\n'. On se demande alors comment on va pouvoir lire ou écrire dans un fichier, à travers un flux de texte, de manière portable. Et bien c'est beaucoup plus simple que ce à quoi vous-vous attendiez : lorsqu'on effectue une opération d'entrée/sortie sur un flux de texte, les données seront lues/écrites de façon à ce qu'elles correspondent à la manière dont elles doivent être représentées et non caractère pour caractère. C'est-à-dire par exemple que, dans une implémentation où la fin de ligne est provoquée par la combinaison des caractères CR et LF, l'écriture de '\n' sur un flux de texte va provoquer l'écriture effective des caractères '\r' et '\n' dans le fichier associé. Sur un flux binaire les données sont lues ou écrites dans le fichier caractère pour caractère.

I-B. Les fichiers sur disque

La communication avec une ressource externe (un modem, une imprimante, une console, un périphérique de stockage, etc.) nécessite un protocole de communication (qui peut être texte ou binaire, simple ou complexe, ...) spécifique de cette ressource et qui n'a donc rien à voir le langage C. La norme définit tout simplement les fonctions permettant d'effectuer les entrées/sorties vers un fichier sans pour autant définir la notion de matériel ou même de fichier sur disque afin de garantir la portabilité du langage (ou plus précisément de la bibliothèque). Cependant, afin de nous fixer les idées, nous n'hésiterons pas à faire appel à ces notions dans les explications.

Les fichiers sur disque servent à stocker des informations. On peut « naviguer » à l'intérieur d'un tel fichier à l'aide de fonctions de positionnement (que nous verrons un peu plus loin). A chaque instant, un « pointeur » indique la position courante dans le fichier. Ce pointeur se déplace, à quelques exceptions près, après chaque opération de lecture, d'écriture ou appel d'une fonction de positionnement par exemple. Bien entendu, cette notion de position n'est pas une spécificité exclusive des fichiers sur disque. D'autres types de fichiers peuvent très bien avoir une structure similaire.

I-C. Les messages d'erreur

En langage C, il est coutume de retourner un entier même quand une fonction n'est censée retourner aucune valeur. Cela permet au programme de contrôler les erreurs et même d'en connaître la cause. Certaines fonctions comme malloc par exemple retournent cependant une adresse, NULL en cas d'erreur. Pour fournir d'amples informations quant à la cause de l'erreur, ces fonctions placent alors une valeur dans une variable globale appelée errno (en fait la norme n'impose pas qu'errno doit être forcément une variable globale !), cette valeur bien entendu est à priori dépendante de l'implémentation. Ces valeurs doivent être déclarées dans le fichier errno.h. Par exemple, une implémentation peut définir un numéro d'erreur ENOMEM qui sera placée dans errno lorsqu'un appel à malloc a échoué car le système manque de mémoire.

La fonction strerror, déclarée dans string.h, permet de récupérer une chaîne à priori définie par l'implémentation décrivant l'erreur correspondant au numéro d'erreur passé en argument. La fonction perror, déclarée dans stdio.h, quant à elle utilise cette chaîne, plus une chaîne fournie en argument, pour afficher la description d'une erreur. Le texte sera affiché sur l'erreur standard (stderr). Pour résumer :

perror(msg);

est équivalent à :

fprintf(stderr, "%s: %s\n", msg, strerror(errno));

Si nous n'avons pas parlé de ces fonctions auparavant, c'est parce que nous n'en avions pas vraiment jusqu'ici besoin. A partir de maintenant, elles vont être très utiles car les fonctions d'entrées/sorties sont sujettes à de très nombreuses sortes d'erreurs : fichier non trouvé, espace sur disque insuffisant, on n'a pas les permissions nécessaires pour lire ou écrire dans le fichier, ...

I-D. Ouverture d'un fichier

Toute opération d'E/S dans un fichier commence par l'ouverture du fichier. Lorsqu'un fichier est ouvert, un flux lui est associé. Ce flux est représenté par un pointeur vers un objet de type FILE.

Pendant l'ouverture d'un fichier, on doit spécifier comment en désire l'ouvrir : en lecture (c'est-à-dire qu'on va lire dans le fichier), en écriture (pour écrire), ou en lecture et écriture. La fonction fopen :

FILE * fopen(const char * filename, const char * mode);

permet d'ouvrir un fichier dont le nom est spécifié par l'argument filename selon le mode, spécifié par l'argument mode, dans lequel on souhaite ouvrir le fichier. Elle retourne l'adresse d'un objet de type FILE qui représente le fichier à l'intérieur du programme. En cas d'erreur, NULL est retourné et une valeur indiquant la cause de l'erreur est placée dans errno.

En pratique, dans le cas d'un fichier sur disque, l'argument nom peut être aussi bien un chemin complet qu'un chemin relatif. Notez bien que les notions de chemin, répertoire (qui inclut également les notions de répertoire courant, parent, etc.), ... sont dépendantes du système, elles ne font pas partie du langage C. La plupart du temps, le répertoire courant est par défaut celui dans lequel se trouve le programme mais, si le système le permet, il est possible de spécifier un répertoire différent.

Fondamentalement, les valeurs suivantes peuvent être utilisées dans l'argument mode :

  • "r" : ouvrir le fichier en lecture. Le fichier spécifié doit déjà exister.
  • "w" : ouvrir le fichier en écriture. S'il n'existe pas, il sera créé. S'il existe déjà, son ancien contenu sera effacé.
  • "a" : ouvrir le fichier en mode ajout, qui est un mode dans lequel toutes les opérations d'écriture dans le fichier se feront à la fin du fichier. S'il n'existe pas, il sera créé.

Quel que soit le cas, le fichier sera associé à un flux de texte. Pour spécifier qu'on veut ouvrir le fichier en tant que fichier binaire, il suffit d'ajouter le suffixe 'b' (c'est-à-dire "rb", "wb" ou "ab").

Sauf dans le cas du mode "ab" et dérivés, dans lequel le pointeur pourrait, selon l'implémentation, être positionné à la fin du fichier, il sera positionné au début du fichier.

On peut également ajouter un '+' dans l'argument mode. Par exemple : "r+", "r+b" ou encore "rb+" mais "r+b" et "rb+", c'est évidemment la même chose. La présence du '+' aura pour effet de permettre d'effectuer aussi bien des opérations de lecture que d'écriture sur le fichier. On dit alors que le fichier est ouvert en mode mise à jour. Il y a cependant une remarque très importante concernant les fichiers ouverts en mode mise à jour :

  • avant d'effectuer une opération de lecture juste après une opération d'écriture, il faut tout d'abord appeler fflush ou une fonction de positionnement

  • avant d'effectuer une opération d'écriture juste après une opération de lecture, il faut d'abord appeler une fonction de positionnement, à moins d'avoir atteint la fin du fichier

  • dans de nombreuses implémentations, tout fichier ouvert en mode mise à jour est supposé être un fichier binaire qu'on ait mis oui ou non la lettre 'b'. Personnellement, je recommande donc de n'utiliser le mode mise à jour qu'avec les fichiers binaires.

Lorsqu'on n'en a plus besoin, il faut ensuite fermer le fichier :

int fclose(FILE * f);

Le programme suivant crée un fichier, hello.txt, pour y écrire ensuite une et une seule ligne : Hello, world.

#include <stdio.h>

int main()
{
    FILE * f;

    f = fopen("hello.txt", "w");
    if (f != NULL)
    {
        fprintf(f, "Hello, world\n");
        fclose(f);
    }
    else
        perror("hello.txt");

    return 0;
}

En supposant que pour le système la fin de ligne est représentée par la combinaison des caractères CR et LF, ce programme est aussi équivalent au suivant :

#include <stdio.h>

int main()
{
    FILE * f;

    f = fopen("hello.txt", "wb");
    if (f != NULL)
    {
        fprintf(f, "Hello, world\r\n");
        fclose(f);
    }
    else
        perror("hello.txt");

    return 0;
}

I-E. Exemple : copier un fichier

Il y a plusieurs moyens de réaliser la copie d'un fichier, le plus simple est peut-être de copier la source vers la destination octet par octet. Voici un programme qui réalise une telle copie :

#include <stdio.h>
#include <string.h>

char * saisir_chaine(char * lpBuffer, int buf_size);

int main()
{
    char src[FILENAME_MAX]; /* FILENAME_MAX est defini dans stdio.h */
    FILE * fsrc;

    printf("Ce programme permet de copier un fichier.\n");
    printf("source : ");
    saisir_chaine(src, sizeof(src));

    fsrc = fopen(src, "rb");
    if (fsrc == NULL)
        perror(src);
    else
    {
        char dest[FILENAME_MAX];
        FILE * fdest;

        printf("dest : ");
        saisir_chaine(dest, sizeof(dest));

        if (strcmp(src, dest) == 0)
            printf("La source ne peut pas etre en meme temps la destination.\n");
        else
        {
            fdest = fopen(dest, "wb");
            if (fdest == NULL)
                perror(dest);
            else
            {
                int c;

                while ((c = getc(fsrc)) != EOF)
                    putc(c, fdest);

                fclose(fdest);
                printf("Copie terminee.\n");
            }
        }

        fclose(fsrc);
    }

    printf("Merci d'avoir utilise ce programme. A bientot !\n");

    return 0;
}

char * saisir_chaine(char * lpBuffer, int buf_size)
{
    char * ret = fgets(lpBuffer, buf_size, stdin);

    if (ret != NULL)
    {
        char * p = strchr(lpBuffer, '\n');
        if (p != NULL)
            *p = '\0';
        else
        {
            int c;

            do
                c = getchar();
            while (c != EOF && c != '\n');
        }
    }

    return ret;
}

II. Les erreurs d'E/S

Une erreur d'E/S est erreur qui peut se produire lors d'une tentative d'opération d'E/S, par exemple : fin de fichier atteinte, tentative d'écriture dans un fichier ouvert uniquement en lecture, tentative de lecture dans un fichier ouvert uniquement en lecture, etc.

La fonction feof :

int feof(FILE * f);

peut être appelé à n'importe quel moment pour connaître si l'on a atteint la fin du fichier. Je rappelle qu'un programme (du moins d'après les fonctions du C) ne peut affirmer que la fin d'un fichier a été atteinte qu'après avoir tenté de lire dans le fichier alors qu'on se trouve déjà à la fin c'est-à-dire derrière le dernier octet, pas juste après avoir lu le dernier octet, donc faites gaffe. Evidemment, une telle fonction ne sera utilisée que sur un fichier ouvert en lecture. Elle retourne VRAI si la fin de fichier a été atteinte et FAUX dans le cas contraire. Cette information est en fait maintenue par un indicateur de fin de fichier qui indique à tout moment si on a oui ou non déjà atteint la fin du fichier. Après un appel fructueux à une fonction de positionnement, l'indicateur de fin de fichier est remis à zéro.

La fonction ferror :

int ferror(FILE * f);

permet de connaître si une erreur différente de "fin de fichier atteinte" s'est produite lors de la dernière opération d'E/S sur f. Cette information est maintenue en permanence par un indicateur d'erreur. Bien qu'une lecture à la fin d'un fichier soit également une erreur, elle n'est pas considérée par la fonction ferror car les indicateurs d'erreur et de fin de fichier sont des données bien distinctes.

Attention, une fois qu'une erreur s'est produite, l'indicateur d'erreur ne sera remis à zéro qu'après un appel à clearerr.

Sélectionnez
void clearerr(FILE * f);

Cette fonction remet à zéro l'indicateur d'erreur du fichier f.

III. Positionnement dans un fichier

La fonction fseek

int fseek(FILE * f, long offset, int origin);

Permet de modifier la position courante dans un fichier. La nouvelle position dépend des valeurs des arguments offset qui représente le déplacement et origin qui représente l'origine. Les valeurs qu'on peut donner à origin sont :

  • SEEK_SET : le pointeur sera ramené à offset caractères par rapport au début du fichier
  • SEEK_CUR : le pointeur sera ramené à offset caractères par rapport à la position courante
  • SEEK_END : le pointeur sera ramené à offset caractères par rapport à la fin du fichier.

Bien entendu, le déplacement peut être positif ou négatif. Si la fonction réussit, 0 est retourné (une valeur différente de zéro indique donc une erreur).

L'utilisation de cette fonction avec un flux de texte peut donner des résultats inattendus à cause du coup du '\n' ... Pour aller au n-ième caractère d'un fichier associé à un flux de texte (ce qui entraîne que la fin de ligne sera vue du programme comme un et un seul caractère : '\n') par exemple, utilisez la méthode suivante :

fseek(f, 0L, SEEK_SET);
for(i = 0; i < n; i++)
    getc(f);

/* On est maintenant a la position n dans le fichier */

Cette technique est aussi connue sous le nom d'accès séquentiel, par opposition à accès aléatoire (utilisant fseek, etc.).

Dans la pratique, on ne sera jamais confronté à un tel cas. Bon, « jamais » c'est peut être un peu trop mais en tout cas je peux vous affirmer que je n'ai jamais eu à le faire. Si on doit faire beaucoup de « va et vient » dans un fichier, c'est tout simplement une erreur de l'avoir ouvert en tant que fichier texte.

La fonction ftell

long ftell(FILE * f);

Permet de connaître la position courante dans le fichier. Dans le cas où le fichier est associé à un flux binaire, il s'agit du nombre de caractères entre le début du fichier et la position courante. Si le fichier est associé à un flux de texte, la valeur retournée par cette fonction est tout simplement une information représentant la position actuelle dans le fichier et on ne peut rien dire de plus. Quel que soit le cas, le retour de ftell pourra éventuellement être utilisée dans un futur appel fseek avec SEEK_SET pour revenir à la même position.

Les fonctions fgetpos et fsetpos

La fonction fgetpos permet de sauvegarder la position courante dans un fichier. On pourra ensuite ultérieurement revenir à cette position à l'aide de la fonction fsetpos.

int fgetpos(FILE * f, fpos_t * p_pos);
int fsetpos(FILE * f, const fpos_t * p_pos);

Le type fpos_t est un type limité à usage de ces fonctions. On ne doit passer à fsetpos qu'une valeur obtenue à l'aide de fgetpos. En cas de succès, 0 est retourné.

La fonction rewind

Cette fonction permet de « rembobiner » un fichier.

rewind(f);

est équivalent à :

fseek(f, 0L, SEEK_SET);
clearerr(f);

Position courante

En général, les opérations de lecture (respectivement d'écriture) commencent la lecture (respectivement l'écriture) à partir de la position courante puis ensuite déplacent le pointeur selon le nombre de caractères lus (respectivement écrits). Il existe toutefois des exceptions comme dans le cas ou le fichier est ouvert en mode ajout par exemple, auquel cas toutes les opérations d'écriture se feront à la fin du fichier, indépendamment de la position courante.

IV. Le traitement par blocs

La bibliothèque standard offre également des fonctions permettant de réaliser des opérations d'entrées/sorties par blocs d'octets. Il s'agit des fonctions fread et fwrite.

size_t fread(void * buffer, size_t size, size_t nobj, FILE * f);
size_t fwrite(const void * buffer, size_t size, size_t nobj, FILE * f);

fread lit nobj objets de taille size chacune à partir de la position courante dans f pour les placer dans un buffer. Elle retourne ensuite le nombre d'objets effectivement lus.

A l'inverse fwrite écrit nobj objets de buffer de taille size chacune en destination de f. Elle retourne ensuite le nombre d'objets effectivement écrits.

Par exemple, voici une manière d'écrire "Bonjour" dans un fichier en utilisant fwrite.

#include <stdio.h>
#include <string.h>

int main()
{
    FILE * f;
    char s[] = "Bonjour";

    f = fopen("bonjour.txt", "wb");

    if (f != NULL)
    {
        fwrite(s, sizeof(char), strlen(s), f);
        fclose(f);
    }
    else
        perror("bonjour.txt");

    return 0;
}

V. Opérations sur les fichiers

V-A. Renommer ou déplacer un fichier

La fonction rename :

int rename(const char * oldname, const char * newname);

permet de renommer un fichier lorsque cela est possible. Si la fonction réussit, 0 est retourné.

V-B. Supprimer un fichier

La fonction remove :

int remove(const char * filename);

permet de supprimer un fichier. Si la fonction réussit, 0 est retourné.

VI. Les fichiers temporaires

Un fichier temporaire est un fichier utilisé par un programme puis supprimé lorsque celui-ci se termine. La fonction tmpfile :

FILE * tmpfile(void);

permet de créer un fichier en mode "wb+" qui sera automatiquement supprimé à la fin du programme.

On peut toujours bien sûr créer un fichier temporaire « manuellement ». Dans ce cas, il vaut mieux lui donner un nom généré par tmpnam afin de s'assurer qu'aucun autre fichier porte déjà ce nom.

Sélectionnez
char * tmpnam(char * name);

Si name est utilisé, il doit pointer sur un buffer d'au moins L_tmpnam octets autrement il est possible qu'elle ne pourra pas contenir le nom généré par cette fonction ce qui va provoquer un débordement de tampon ! On peut tout simplement passer NULL à cette fonction si on n'a pas de buffer à passer en argument.

VI. Rediriger un flux d'E/S

La fonction freopen :

FILE * freopen(const char * filename, const char * mode, FILE * f);

ferme le fichier associé au flux f, ouvre le fichier dont le nom est spécifié par filename selon le mode spécifié par mode en lui associant le flux représenté par f puis retourne f ou NULL si une erreur s'est produite. Dans le programme suivant, la sortie standard est redirigée vers le fichier out.txt.

#include <stdio.h>

int main()
{
    if (freopen("out.txt", "w", stdout) != NULL)
    {
        printf("Hello, world\n");
        fclose(stdout);
    }
    else
        perror("out.txt");

    return 0;
}


Les structures de données en C : les piles.

Les piles en C

Table des matières>

  • I. Introduction
  • II. Principe
  • III. En pratique
  • IV. Conclusion

Les structures de données en C.

I. Introduction

Pour poursuivre la découverte des différentes structures de données, nous allons maintenant nous attarder sur les piles.

II. Principe

Les piles peuvent être représentées comme une pile d'assiettes, vous pouvez ajouter des assiettes au sommet de la pile et lorsque vous voulez en enlever une, il s'agit de la dernière ajoutée : on parle de liste LIFO (Last In First Out). Les piles ne sont que des cas particuliers de listes chaînées dont les éléments ne peuvent être ajoutés et supprimés qu'en fin de liste. De ce fait la manipulation s'en trouve grandement simplifiée puisqu'elle ne nécessite que deux fonctions :

  • Une fonction pour ajouter un élément au sommet de la pile
  • Une seconde pour le retirer

III. En pratique

III-A. La structure d'un élément

Pour représenter un élément de la pile, il nous suffit de reprendre la structure d'un élément d'une liste doublement chaînée.

typedef struct stack
{
   struct stack *prev;
   struct stack *next;
   void *data;
} stack_s;

III-B. Initialisation

Rien de nouveau côté initialisation, on s'assure juste que le pointeur qui servira de pile est bien mit à NULL.

stack_s *stack_new (void)
{
   return (NULL);
}

III-C. Empiler un élément

Les éléments ne peuvent être ajoutés qu'en fin de liste, il n'est donc plus nécessaire de se préoccuper d'un éventuel élément suivant. A part ce détail, c'est exactement le même code que pour les listes doublement chaînées.

Représentation de l'ajout d'un élément à une pile Figure 1: représentation de l'ajout d'un élément à une pile.
  1. Voici l'état de la pile avant l'appel de la fonction
  2. Le pointeur p_p représente l'argument de la fonction, c'est donc après lui qu'il faut insérer un élément
  3. Création d'un nouvel élément pointé par p_l
  4. On ajoute le nouvel élément à la fin de la pile
  5. Dernier point qui n'est pas présent sur le schéma ci-dessus pourtant trés important : faire pointer pp_pile sur le nouvel élément
void stack_push (stack_s ** pp_stack, void *data)
{
   if (pp_stack != NULL)
   {
/* (2) */
      stack_s *p_p = *pp_stack;
      stack_s *p_l = NULL;

/* (3) */
      p_l = malloc (sizeof (*p_l));
      if (p_l != NULL)
      {
         p_l->data = data;
/* (4) */
         p_l->next = NULL;
         p_l->prev = p_p;
         if (p_p != NULL)
            p_p->next = p_l;
*/ (5) */
         *pp_stack = p_l;
      }
      else
      {
         fprintf (stderr, "Memoire insuffisante\n");
         exit (EXIT_FAILURE);
      }
   }
   return;
}

III-D. Dépiler un élément

L'élément à dépiler correspond obligatoirement au dernier ajouté qui se trouve au sommet de la pile. Généralement un élément est retiré de la pile pour pouvoir être utilisé, il faut donc retourner l'élément retiré.

Représentation de la supression d'un élément d'une pile Figure 2: représentation de la suppression d'un élément d'une pile.
  1. Voici l'état de la liste avant l'appel de la fonction
  2. Le pointeur p_l représente l'argument de la fonction et donc l'élément à supprimer
  3. Sauvegarde de l'élément précédent celui à supprimer grâce au pointeur p_p
  4. Libération de la mémoire
  5. On met le pointeur next de p_p à NULL
  6. Même remarque que pour l'ajout d'un élément : ne pas oublier de se placer au sommet de la pile
void *stack_pop (stack_s ** pp_stack)
{
   void *ret = NULL;

   if (pp_stack != NULL && *pp_stack != NULL)
   {
/* (2) */
      stack_s *p_l = *pp_stack;
/* (3) */
      stack_s *p_p = p_l->prev;

      if (p_p != NULL)
         p_p->next = NULL;
      ret = p_l->data;
/* (4) */
      free (p_l);
      p_l = NULL;
/* (5) */
      *pp_stack = p_p;
   }
   return (ret);
}

III-E. Suppression de la pile

Pour supprimer la liste, il suffit de dépiler tous ses éléments.

void stack_delete (stack_s ** pp_stack)
{
   if (pp_stack != NULL && *pp_stack != NULL)
   {
      while (*pp_stack != NULL)
         stack_pop (pp_stack);
   }
   return;
}

IV. Conclusion

La création de la bibliothèque de manipulation des piles est grandement simplifiée du fait qu'elle s'inspire des listes doublement chaînées. Il est tout à fait possible d'obtenir le même résultat avec les listes simplement chaînées, je vous laisse le faire en guise d'exercice.

VII. Remerciements

Merci à Gnux pour la relecture attentive de cet article.

VI. Code source complet

Téléchargez l'archive zippée.

pile.h
#ifndef H_PILE
#define H_PILE

typedef struct stack stack_s;

stack_s *stack_new (void);
void stack_push (stack_s **, void *);
void *stack_pop (stack_s **);
void stack_delete (stack_s **);

#endif /* not H_PILE */
pile.c
#include <stdio.h>
#include <stdlib.h>
#include "pile.h"

struct stack
{
   struct stack *prev;
   struct stack *next;
   void *data;
};

stack_s *stack_new (void)
{
   return (NULL);
}

void stack_push (stack_s ** pp_stack, void *data)
{
   if (pp_stack != NULL)
   {
      stack_s *p_p = *pp_stack;
      stack_s *p_l = NULL;

      p_l = malloc (sizeof (*p_l));
      if (p_l != NULL)
      {
         p_l->data = data;
         p_l->next = NULL;
         p_l->prev = p_p;
         if (p_p != NULL)
            p_p->next = p_l;
         *pp_stack = p_l;
      }
      else
      {
         fprintf (stderr, "Memoire insuffisante\n");
         exit (EXIT_FAILURE);
      }
   }
   return;
}

void *stack_pop (stack_s ** pp_stack)
{
   void *ret = NULL;

   if (pp_stack != NULL && *pp_stack != NULL)
   {
      stack_s *p_l = *pp_stack;
      stack_s *p_p = p_l->prev;

      if (p_p != NULL)
         p_p->next = NULL;
      ret = p_l->data;
      free (p_l);
      p_l = NULL;
      *pp_stack = p_p;
   }
   return (ret);
}

void stack_delete (stack_s ** pp_stack)
{
   if (pp_stack != NULL && *pp_stack != NULL)
   {
      while (*pp_stack != NULL)
         stack_pop (pp_stack);
   }
   return;
}


Les structures de données en C : les files.

Les files en C

Table des matières

  • I. Introduction
  • II. Principe
  • III. En Pratique
  • IV. Conclusion
  • V. Remerciements
  • VI. Code source complet

Les structures de données en C.

I. Introduction

Dernière structure de donnée basée sur les listes (enfin !), il s'agit des files. Pour nous faciliter la vie, et ne pas réinventer la roue, nous allons utiliser la bibliothèque de listes doublement chaînées pour créer celle des files.

II. Principe

Les files sont très similaires aux piles, la seule différence se situe au niveau de l'ajout de nouvel élément : il se fait en début de liste. Par contre la suppression se fait toujours en fin de liste. Les files sont aussi appelée liste FIFO (First In First Out).
Malgré un fonctionnement proche de celui des piles, la bibliothèque de gestion des files va être plus complexe, en effet, il faut se déplacer au début ou à la fin de la liste suivant que nous ajoutons ou retirons un élément.

III. En Pratique

III-A. La structure d'un élément

Pour représenter un élément de la pile, il nous suffit de reprendre la structure d'un élément d'une liste doublement chaînée.

typedef struct queue
{
   struct queue *prev;
   struct queue *next;
   void *data;
} queue_s;

III-B. Initialisation

On initialise le pointeur qui servira de file :

queue_s *queue_new (void)
{
   return (NULL);
}

III-C. Ajouter un élément

Les éléments doivent être ajoutés au début, il faut donc insérer le nouvel élément avant le premier de la liste.

Ajout d'un élément à une file Figure 1 : ajout d'un élément à une file
  1. Voici l'état de la file avant l'appel de la fonction
  2. On se positionne au début de la file grâce au pointeur p_l, c'est donc avant qu'il faut ajouter un élément
  3. Création d'un nouvel élément pointé par p_p
  4. On fait pointer le nouvel élément sur le premier maillon de la file : p_p et inversement
  5. Etat de la liste après l'appel de la fonction

Et maintenant la même chose mais en C :

void queue_post (queue_s ** pp_queue, void *data)
{
   if (pp_queue != NULL)
   {
      queue_s *p_l = NULL;
      queue_s *p_p = NULL;

      queue_first (pp_queue);
/* (2) */
      p_l = *pp_queue;
/* (3) */
      p_p = malloc (sizeof (*p_p));
      if (p_p != NULL)
      {
         p_p->data = data;
/* (4) */
         p_p->next = p_l;
         p_p->prev = NULL;
         if (p_l != NULL)
            p_l->prev = p_p;
/* (5) */
         *pp_queue = p_p;
      }
      else
      {
         fprintf (stderr, "Memoire insuffisante\n");
         exit (EXIT_FAILURE);
      }
   }
   return;
}

Voici le détail de la fonction queue_first qui permet de se déplacer au début de la file :

static void queue_first (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while ((*pp_queue)->prev != NULL)
         queue_prev (pp_queue);
   }
   return;
}

static void queue_prev (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
      *pp_queue = (*pp_queue)->prev;
   return;
}

Ces fonctions sont déclarées comme static puisqu'elles n'ont plus à être utilisées hors de la bibliothèque.

III-D. Extraire un élément

Ajout d'un élément de la file Figure 2 : suppression d'un élément de la file
  1. Voici l'état de la file avant l'appel de la fonction
  2. On se positionne à la fin de la file grâce au pointeur p_l, c'est l'élément à supprimer
  3. On sauvegarde le futur dernier élément de la file grâce au pointeur p_p
  4. Libération de la mémoire
  5. On met le pointeur next de p_p à NULL pour marquer la fin de la file
void *queue_get (queue_s ** pp_queue)
{
   void *ret = NULL;

   if (pp_queue != NULL && *pp_queue != NULL)
   {
      queue_s *p_l = NULL;
      queue_s *p_p = NULL;

/* (2) */
      queue_last (pp_queue);
      p_l = *pp_queue;
/* (3) */
      if (p_l != NULL)
         p_p = p_l->prev;
      ret = p_l->data;
/* (4) */
      free (p_l);
      p_l = NULL;
/* (5) */
      if (p_p != NULL)
         p_p->next = NULL;
      *pp_queue = p_p;
   }
   return (ret);
}

Et bien sûr le code de la fonction queue_last :

static void queue_last (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while ((*pp_queue)->next != NULL)
         queue_next (pp_queue);
   }
   return;
}

static void queue_next (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
      *pp_queue = (*pp_queue)->next;
   return;
}

III-E. Suppression de la file

Pour supprimer la liste, il suffit de retirer tous ses éléments.

Sélectionnez
void queue_delete (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while (*pp_queue != NULL)
         queue_get (pp_queue);
   }
   return;
}

IV. Conclusion

Cet article termine le premier chapitre de cette série. Avant de passer à la seconde grosse partie (les arbres), nous allons faire une petite pause avec l'étude des tables de hachages.

V. Remerciements

Merci à Maximilian pour la relecture attentive de cet article.

VI. Code source complet

Téléchargez l'archive zippée.

file.h
#ifndef H_FILE
#define H_FILE

typedef struct queue queue_s;

queue_s *queue_new (void);
void queue_post (queue_s **, void *);
void *queue_get (queue_s **);
void queue_delete (queue_s **);

#endif /* not H_FILE */
file.c
#include <stdio.h>
#include <stdlib.h>
#include "file.h"

struct queue
{
   struct queue *prev;
   struct queue *next;
   void *data;
};

static void queue_first (queue_s **);
static void queue_prev (queue_s **);
static void queue_last (queue_s **);
static void queue_next (queue_s **);

queue_s *queue_new (void)
{
   return (NULL);
}

void queue_post (queue_s ** pp_queue, void *data)
{
   if (pp_queue != NULL)
   {
      queue_s *p_l = NULL;
      queue_s *p_p = NULL;

      queue_first (pp_queue);
      p_l = *pp_queue;
      p_p = malloc (sizeof (*p_p));
      if (p_p != NULL)
      {
         p_p->data = data;
         p_p->next = p_l;
         p_p->prev = NULL;
         if (p_l != NULL)
            p_l->prev = p_p;
         *pp_queue = p_p;
      }
      else
      {
         fprintf (stderr, "Memoire insuffisante\n");
         exit (EXIT_FAILURE);
      }
   }
   return;
}

void *queue_get (queue_s ** pp_queue)
{
   void *ret = NULL;

   if (pp_queue != NULL && *pp_queue != NULL)
   {
      queue_s *p_l = NULL;
      queue_s *p_p = NULL;

      queue_last (pp_queue);
      p_l = *pp_queue;
      if (p_l != NULL)
         p_p = p_l->prev;
      ret = p_l->data;
      free (p_l);
      p_l = NULL;
      if (p_p != NULL)
         p_p->next = NULL;
      *pp_queue = p_p;
   }
   return (ret);
}

void queue_delete (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while (*pp_queue != NULL)
         queue_get (pp_queue);
   }
   return;
}

static void queue_first (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while ((*pp_queue)->prev != NULL)
         queue_prev (pp_queue);
   }
   return;
}

static void queue_prev (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
      *pp_queue = (*pp_queue)->prev;
   return;
}

static void queue_last (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
   {
      while ((*pp_queue)->next != NULL)
         queue_next (pp_queue);
   }
   return;
}

static void queue_next (queue_s ** pp_queue)
{
   if (pp_queue != NULL && *pp_queue != NULL)
      *pp_queue = (*pp_queue)->next;
   return;
}


Les nombres flottants et leurs pièges

Table des matières

  • I. Introduction
  • II. Norme IEE 754-1985
  • III. Représentation de quelques nombres
  • IV. Remerciements

La série d'articles de Bruce Dawson aborde en détail les problématiques liées à la représentation des nombres à virgule flottante. Ce premier article pose les bases et explore le monde étrange et merveilleux des mathématiques à virgule flottante.
Retrouvez l'ensemble des articles de cette série sur la page d'index.

I. Introduction

Il y a quelques années, j'ai écrit un article expliquant comment comparer des nombres à virgule flottante en utilisant des comparaisons d'entiers. Cet article a été plutôt populaire (il est fréquemment cité et les exemples de code ont été utilisés par nombre d'entreprises) et ça me fait un peu peur, parce que l'article a quelques défauts. Je ne vais pas donner de lien vers l'article : je veux le remplacer, et donc ne pas envoyer les gens le lire.

Aujourd'hui, je vais commencer par poser les bases en expliquant comment et pourquoi cette astuce fonctionne, tout en explorant le monde étrange et merveilleux des mathématiques à virgule flottante.

Il y a beaucoup de références qui expliquent la disposition binaire et le décodage des nombres à virgule flottante. Dans ce billet, je vais présenter l'organisation binaire et expliquer comment fonctionne le procédé de décodage par des expérimentations et de la rétroconception.

II. Norme IEE 754-1985

Le standard IEEE 754-1985 spécifie le format des nombres à virgule flottante de 32 bits, type connu sous le nom float dans de nombreux langages. La version 2008 du standard ajoute de nouveaux formats, mais ne modifie pas les existants, qui sont standardisés depuis plus de 25 ans.

Un flottant (1) de 32 bits consiste en un champ d'un bit pour le signe, un champ de 8 bits pour l'exposant et un champ de 23 bits pour la mantisse. L'union ci-dessous montre la disposition des bits d'un tel flottant. Cette union nous sera très utile pour explorer et travailler sur la représentation interne des nombres à virgule flottante. Je ne recommanderais pas d'utiliser cette union pour du code de production (elle est en violation des règles d'aliasing (2) établies par certains compilateurs et ceux-ci vont donc probablement générer du code inefficace), mais elle nous sera utile pour apprendre. Ces articles sur l'aliasing et l'undefined behavior (comportement indéfini) proposent des détails supplémentaires.

union Float_t
{
    Float_t(float num = 0.0f) : f(num) {}
    // extraction portable des composants
    bool Negative() const { return (i >> 31) != 0; }
    int32_t RawMantissa() const { return i & ((1 << 23) - 1); }
    int32_t RawExponent() const { return (i >> 23) & 0xFF; }

    int32_t i;
    float f;
#ifdef _DEBUG
    struct
    {
        // Champs de bits pour l'exploration. Ne pas utiliser en production.
        uint32_t mantissa : 23;
        uint32_t exponent : 8;
        uint32_t sign : 1;
    } parts;
#endif
};

Le format des nombres flottants de 32 bits a été attentivement conçu pour leur permettre d'être réinterprété comme un entier et l'aliasing de i et f devrait fonctionner sur la majorité des plates-formes (si, comme gcc et VC++, elles autorisent l'aliasing par les unions), avec le bit de signe de l'entier et du flottant occupant la même position.

La disposition des champs de bits est dépendante du compilateur, donc la structure à champs de bits parts, qui est dans l'union, peut ne pas fonctionner sur toutes les plates-formes. Cependant, ceci fonctionne avec Visual C++ sur x86 et x64, ce qui est suffisant pour ce que je souhaite tester. Sur des systèmes big endian (3) comme SPARC et PPC, l'ordre dans la structure à champs de bits est inversé.

III. Représentation de quelques nombres

Afin de vraiment comprendre les flottants, il est important d'explorer et d'expérimenter. Une façon d'explorer est d'écrire du code comme ceci, en compilant en mode « debug » afin que le débogueur ne le retire pas en l'optimisant :

void TestFunction()
{
    Float_t num(1.0f);
    num.i -= 1;
    printf("Valeur flottante, representation, signe, exposant, mantisse\n");
    for (;;)
    {
        // point d'arrêt ici
        printf("%1.8e, 0x%08X, %d, %d, 0x%06X\n",
            num.f, num.i,
            num.parts.sign, num.parts.exponent, num.parts.mantissa);
    }
}

Placez un point d'arrêt sur la ligne du printf, puis ajoutez les différents composants de num à votre fenêtre de débogueur et examinez-les, ce qui pourra donner cela par exemple :

Image non disponible

Vous pouvez alors commencer à faire des tests interactifs, comme incrémenter la mantisse ou l'exposant, incrémenter num.i, ou inverser le bit de signe. En faisant cela, observez num.f afin de voir de quelle façon il évolue. Ou bien, assignez différentes valeurs de flottants à num.f et regardez comment les autres champs changent. Vous pouvez soit observer les résultats dans la fenêtre du débogueur, soit appuyer sur Run après chaque changement, afin que le printf s'exécute et affiche des résultats joliment formatés.

Allez-y. Placez Float_t et le code d'exemple dans un projet et jouez avec pendant quelques minutes. Découvrez les valeurs minimum et maximum des valeurs flottantes. Expérimentez avec les valeurs minimum et maximum de mantisse dans des combinaisons variées. Pensez aux conclusions. Ceci est la meilleure façon d'apprendre. J'attendrai.

Image non disponible

J'ai placé quelques-uns des résultats que vous pouvez rencontrer pendant ces expériences dans le tableau ci-dessous :

Valeur du flottant | Représentation entière | Signe | Exposant | Mantisse
0.0 | 0×00000000 | 0 | 0 | 0
1.40129846e-45 | 0×00000001 | 0 | 0 | 1
1.17549435e-38 | 0×00800000 | 0 | 1 | 0
0.2 | 0x3E4CCCCD | 0 | 124 | 0x4CCCCD
1.0 | 0x3F800000 | 0 | 127 | 0
1.5 | 0x3FC00000 | 0 | 127 | 0×400000
1.75 | 0x3FE00000 | 0 | 127 | 0×600000
1.99999988 | 0x3FFFFFFF | 0 | 127 | 0x7FFFFF
2.0 | 0×40000000 | 0 | 128 | 0
16,777,215 | 0x4B7FFFFF | 0 | 150 | 0x7FFFFF
3.40282347e+38 | 0x7F7FFFFF | 0 | 254 | 0x7FFFFF
Infini positif | 0x7f800000 | 0 | 255 | 0

Avec ces informations nous pouvons commencer à comprendre le décodage des flottants. Les flottants utilisent un format en écriture scientifique base 2, donc nous pouvons nous attendre à ce que le décodage soit mantisse × 2^exposant. Cependant, dans l'encodage de 1.0 et 2.0 la mantisse est nulle, donc comment ceci peut-il fonctionner ? Ça fonctionne grâce à une astuce. Les nombres normalisés en notation scientifique base 2 sont toujours de la forme :

Image non disponible

donc stocker le chiffre 1 initial est inutile. En l'omettant, on gagne un bit supplémentaire de précision – le champ de 23 bits d'un flottant gère en fait 24 bits de précision car il y a un 1 implicite avec une valeur de 0x800000.

L'exposant pour 1.0 devrait être nul mais le champ exposant vaut en fait 127. C'est parce que exposant est stocké sous une forme augmentée de 127. Pour convertir la valeur dans le champ exposant en la valeur de l'exposant, vous devez simplement soustraire 127.

Les deux exceptions à cette règle de calcul de l'exposant sont quand le champ exposant vaut 255 ou 0. 255 est une valeur pour exposant qui indique que le flottant est soit l'infini soit un NaN (« Not-a-Number »), une mantisse contenant zéro indiquant l'infini. 0 est une valeur spéciale pour exposant, qui indique qu'il n'y a pas de chiffre 1 initial sous-entendu, ce qui signifie que ces nombres ne sont pas normalisés. C'est nécessaire afin de représenter exactement la valeur zéro. La valeur de l'exposant dans ce cas vaut -126, ce qui est la même valeur que lorsque le champ exposant vaut 1.

Pour clarifier les règles d'exposant, j'ai ajouté une colonne « valeur de l'exposant » qui montre l'exposant binaire réel correspondant au champ exposant.

Valeur flottante | Représentation entière | Signe | Champ exposant | Valeur de l'exposant | Mantisse
0.0 | 0×00000000 | 0 | 0 | -126 | 0
1.40129846e-45 | 0×00000001 | 0 | 0 | -126 | 1
1.17549435e-38 | 0×00800000 | 0 | 1 | -126 | 0
0.2 | 0x3E4CCCCD | 0 | 124 | -3 | 0x4CCCCD
1.0 | 0x3F800000 | 0 | 127 | 0 | 0
1.5 | 0x3FC00000 | 0 | 127 | 0 | 0×400000
1.75 | 0x3FE00000 | 0 | 127 | 0 | 0×600000
1.99999988 | 0x3FFFFFFF | 0 | 127 | 0 | 0x7FFFFF
2.0 | 0×40000000 | 0 | 128 | 1 | 0
16,777,215 | 0x4B7FFFFF | 0 | 150 | 23 | 0x7FFFFF
3.40282347e+38 | 0x7F7FFFFF | 0 | 254 | 127 | 0x7FFFFF
Infini positif | 0x7f800000 | 0 | 255 | Infini! | 0

Bien que ces exemples ne le montrent pas, les nombres négatifs sont gérés en mettant 1 dans le champ de signe, ce qui est appelé une forme signe-et-magnitude (« sign-and-magnitude »). Tous les nombres, même zéro et l'infini, ont des versions négatives.

Les nombres dans ce tableau ont été choisis afin de démontrer diverses choses :

  • 0.0 : il est utile que zéro soit représenté par tous les bits à 0. Cependant, il y a aussi un zéro négatif qui a le bit de signe activé. Le zéro négatif est égal au zéro positif ;
  • 1.40129846e-45 : ceci est le plus petit flottant positif et sa représentation entière est le plus petit entier positif ;
  • 1.17549435e-38 : ceci est le plus petit flottant avec un chiffre 1 initial sous-entendu, le plus petit nombre avec un exposant non-zéro, le plus petit flottant normalisé. Ce nombre correspond aussi à FLT_MIN (4). Notez que FLT_MIN n'est pas le plus petit flottant. Il y a en fait à peu près 8 millions de flottants positifs plus petits que FLT_MIN ;
  • 0.2 : ceci est un exemple d'un des nombreux nombres décimaux qui ne peuvent pas être représentés précisément avec un format binaire en virgule flottante. La mantisse devrait répéter C à l'infini ;
  • 1.0 : notez l'exposant et la mantisse et mémorisez la représentation entière au cas où vous la verriez dans des captures hexadécimales ;
  • 1.5 et 1.75 : juste quelques nombres un peu plus grands pour montrer la mantisse qui change alors que l'exposant reste identique ;
  • 1.99999988 : ceci est le plus grand flottant qui a le même exposant que 1.0 et le plus grand flottant qui est plus petit que 2.0 ;
  • 2.0 : notez que exposant est incrémenté de 1 par rapport à 1.0 et que la représentation entière et l'exposant sont tous deux plus grands que ceux de 1.99999988 ;
  • 16777215 : ceci est le plus grand flottant impair. Le flottant suivant a une valeur d'exposant de 24, ce qui signifie que la mantisse est déplacée assez vers la gauche pour que les nombres impairs soient impossibles. Notez que ça signifie qu'au-dessus de 16777215, un flottant a moins de précision qu'un entier ;
  • 3.40282347e+38 : FLT_MAX. Le plus grand flottant fini, avec l'exposant (non « infini ») maximum et la mantisse maximum ;
  • infini positif : le papa ours des flottants.

Nous pouvons maintenant décrire comment décoder le format des flottants :

  • si le champ exposant vaut 255, alors le nombre est infini (si la mantisse est nulle), ou NaN (si la mantisse n'est pas nulle) ;
  • si le champ exposant vaut entre 1 et 254, alors l'exposant vaut entre -126 et 127, il y a un chiffre 1 initial sous-entendu et la valeur du flottant est :
    Image non disponible ;
  • si le champ exposant est nul, alors l'exposant est -126, il n'y a pas de chiffre 1 initial implicite et la valeur du flottant est :
    Image non disponible
  • si le bit de signe est placé alors la valeur du flottant est l'opposé.

L'exposant sous la forme incrémentée de 127 et le chiffre 1 initial omis mènent à quelques caractéristiques très utiles des flottants, mais j'ai beaucoup de choses à dire dessus, donc souvenez-vous-en pour le prochain billet !

Cet article est une traduction autorisée de Tricks With the Floating-Point Format par Bruce Dawson.

(1)
Note de traduction : par abus de langage, l'auteur utilise régulièrement le terme « flottant » à la place de « nombre à virgule flottante ».
(2)
Note de traduction : on parle d'aliasing quand deux objets de deux types différents sont à la même adresse mémoire. Voir Wikipédia - Aliasing (computing) pour plus de détails.
(3)
Note de traduction : big endian se traduit par « gros-boutiste » et définit la représentation mémoire des nombres. Il dépend du processeur. Voir Wikipédia - Endianness pour plus de détails.
(4)
Note de traduction : utiliser std::numeric_limits‹float>::min() est une façon plus « C++ » de récupérer la même valeur que FLT_MIN. De même pour std::numeric_limits‹float>::max() et FLT_MAX.


Opérateurs bit à bit

Opérateurs bit à bit en langage C

Table des matières

  • I. Description des opérateurs bits à bits
  • II. Usages des opérateurs bits à bits

Cet article a pour but de présenter les opérateurs bit à bit et leurs utilisations en langage C.

I. Description des opérateurs bits à bits

I-A. Introduction

Toute donnée informatique est stockée en mémoire sous la forme d'une combinaison de bits. Par exemple un entier valant 10 (base 10) implémenté par une mémoire d'une largeur de 16-bit contient :

0000 0000 0000 1010

Soit en hexadécimal :

000A

Les opérateurs bits permettent de modifier et de tester un ou plusieurs bits d'une donnée. Ces opérateurs sont :

  • NOT (NON) ;
  • AND (ET) ;
  • OR (OU) ;
  • XOR (OU exclusif) ;
  • SHR (décalage à droite) ;
  • SHL (décalage à gauche).

I-B. NOT (NON)

L'opérateur unaire NOT inverse l'état d'un bit selon le tableau suivant :

Table de vérité NOT
A | NOT A
0 | 1
1 | 0

L'opérateur C est ~. Il agit sur chaque bit de la valeur :

unsigned a = 1;
unsigned b = ~a; /* b == 1111111111111110  (en supposant 16-bit) */

I-C. AND (ET)

L'opérateur binaire AND combine l'état de 2 bits selon le tableau suivant :

Table de vérité AND
A | B | A AND B
0 | 0 | 0
0 | 1 | 0
1 | 0 | 0
1 | 1 | 1

L'opérateur C est &. Il agit sur chaque bit de la valeur :

unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a & b; /* c == 0000000011110000 soit 0x00F0 */

I-D. OR (OU)

L'opérateur binaire OR combine l'état de 2 bits selon le tableau suivant :

Table de vérité OR
A | B | A OR B
0 | 0 | 0
0 | 1 | 1
1 | 0 | 1
1 | 1 | 1

L'opérateur C est |. Il agit sur chaque bit de la valeur :

unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a | b; /* c == 1111 0000 1111 1111 soit 0xF0FF */

I-E. XOR (OU exclusif)

L'opérateur binaire OR combine l'état de 2 bits selon le tableau suivant :

Table de vérité XOR
A | B | A XOR B
0 | 0 | 0
0 | 1 | 1
1 | 0 | 1
1 | 1 | 0

L'opérateur C est ^. Il agit sur chaque bit de la valeur :

unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a ^ b; /* c == 1111 0000 0000 1111 soit 0xF00F */

I-F. SHR (Décalage à droite)

L'opérateur binaire SHR a pour opérande de gauche la valeur initiale et pour opérande de droite le nombre de bits à décaler à droite. Les bits de poids faibles sont perdus et les bits de poids forts entrés (à gauche) sont à 0. Ce n'est pas une rotation.

L'opérateur C est >>. Il agit sur chaque bit de la valeur :

unsigned a = 0xF0F0;
unsigned b = 2;
unsigned c = a >> b; /* c == 0011 1100 0011 1100 soit 0x3C3C */

I-G. SHL (Décalage à gauche)

L'opérateur binaire SHL a pour opérande de gauche la valeur initiale et pour opérande de droite le nombre de bits à décaler à gauche. Les bits de poids forts sont perdus et les bits de poids faibles entrés (à droite) sont à 0. Ce n'est pas une rotation.

L'opérateur C est <<. Il agit sur chaque bit de la valeur :

unsigned a = 0xF0F0;
unsigned b = 2;
unsigned c = a << b; /* c == 1100 0011 1100 0000 soit 0xC3C0 */

II. Usages des opérateurs bits à bits

II-A. Manipulations de l'état des bits d'une variable

Si la variable est entière et non signée, il est possible d'utiliser les opérateurs bits pour mettre un ou des bits à 0 ou à 1. Les usages connus sont :

  • compression de données ;
  • commande et état de registres matériels ;
  • etc.

II-B. Positionner un bit à 1

Le principe est de combiner la valeur avec un masque grâce à l'opérateur OU. En effet, comme l'indique la table de vérité, les bits à 0 du masque vont laisser la valeur initiale inchangée, alors les bits à 1 vont s'imposer.

/* mettre à 1 le bit 4 de b : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */
   unsigned b = 0x0010; /* 0000 0000 0001 0000 */
   unsigned c = a | b;  /* 0000 0000 0001 1111 soit 0x001F */

   printf ("%04X OU %04X = %04X\n, a, b, c);

Pour fabriquer le masque, il suffit d'utiliser un 1 que l'on décale à gauche de la valeur correspondante au poids du bit. Par exemple :

Bit 0  : 1u << 0  = 0000 0000 0000 0001
Bit 2  : 1u << 2  = 0000 0000 0000 0100
Bit 15 : 1u << 15 = 1000 0000 0000 0000

Comme pour toute manipulation de bits (y compris avec des constantes), on utilise des valeurs non signées (d'où le 'u').

II-C. Positionner un bit à 0

Le principe est de combiner la valeur avec un masque grâce à l'opérateur ET. En effet, comme l'indique la table de vérité, les bits à 1 du masque vont laisser la valeur initiale inchangée, alors les bits à 0 vont s'imposer.

/* mettre à 0 le bit 3 de b : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */
   unsigned b = 0xFFF7; /* 1111 1111 1111 0111 */
   unsigned c = a & b;  /* 0000 0000 0000 0111 soit 0x0007 */

   printf ("%04X OU %04X = %04X\n, a, b, c);

Pour fabriquer le masque, il suffit d'utiliser un 1 que l'on décale à gauche de la valeur correspondante au poids du bit, puis on inverse les bits avec l'opérateur NON. Par exemple :

Bit 0  : ~(1u << 0)  = 1111 1111 1111 1110
Bit 2  : ~(1u << 2)  = 1111 1111 1111 1011
Bit 15 : ~(1u << 15) = 0111 1111 1111 1111

II-D. Tester la valeur d'un bit

Le principe est d'évaluer le résultat entre la valeur à tester d'une part et un masque à 0, sauf le bit à tester, avec l'opérateur AND. Les bits a 0 restent à 0. Le bit à un passe à un si la valeur lue est 1, sinon, il reste à 0. Si le résultat est 0, le bit est donc à 0. Si il n'est pas 0, il est à 1.

/* tester l'état du bit 2 de a : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */

   if (a & (1u << 2))
   {
      puts("bit 2 = 1");
   }
   else
   {
      puts("bit 2 = 0");
   }

   /* on peut aussi directement récupérer la valeur 0 ou 1
      à l'aide des propriétés de l'opérateur logique ! : */
   printf ("bit 2 = %d\n, !!(a & (1u << 2)));

II-E. Conclusion

Je laisse au lecteur le soin de refaire ces exercices, et trouver le moyen de positionner et tester plusieurs bits d'une même variable.

Ces macros permettent une manipulation aisée des bits d'un entier jusqu'à 32-bit.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


La généricité en langage C

Ce tutoriel vous présente comment intégrer la généricité dans vos projets en C.

I. Introduction

La généricité en C, comme dans d'autres langages, permet d'effectuer des actions, sur un ensemble de données de manière générique, c'est-à-dire quelque soit son type. On peut donc, à l'instar des patrons de fonctions du C++, réaliser des traitements qui pourront s'appliquer sur des types entiers comme des types réels, pourvu que le type qui sera utilisé lors de l'appel de fonction supporte les opérations utilisées par la fonction générique.

II. Le pointeur générique

Le langage C dispose d'un pointeur particulier appelé pointeur générique qui est un pointeur compatible avec tous les autres pointeurs, c'est-à-dire que ce pointeur est à même de pointer vers n'importe quel type d'objet. Ce pointeur est le pointeur void *. Il faut faire attention car ce n'est pas un pointeur vers un objet de type void mais bien un type à part entière. D'ailleurs au passage, un pointeur vers un type void n'a pas de sens puisque le type void n'est pas un type qui doit contenir quelque chose, il est là pour indiquer une absence de type.

II-A. Premiers pas avec le pointeur générique

Vous avez sûrement déjà rencontré le pointeur générique, notamment lors d'allocations mémoire. En effet, la fonction malloc a pour signature :

void * malloc( size_t size)

La fonction renvoie un pointeur générique. Ceci est tout à fait normal puisque la fonction ne sait pas quel doit être le type de retour, ce qui lui importe, c'est la taille à allouer. Ne connaissant pas la taille du type à allouer, elle doit donc renvoyer un pointeur vers n'importe quel type d'objet, il s'agit donc du pointeur générique. Comme le pointeur est générique, il doit pouvoir être affecté sans problème. De plus, on doit pouvoir lui affecter un pointeur sur un type donné. L'exemple suivant montre l'utilisation possible d'un pointeur générique pour afficher l'adresse d'une variable :

#include <stdio.h>
#include <stdlib.h>
int main ( void )
{
void * ptr = NULL;
int a = 2;
ptr = &a;
printf("%p\n",ptr);
return EXIT_SUCCESS;
}

Ici, le pointeur générique remplace donc un pointeur sur un entier.

II-B. Les limites du pointeur générique

Supposons que nous voulions une fonction qui échange deux éléments quels qu'en soient leurs types (il faut au moins que les deux élements soient du même type). A première vue, nous pourrions penser à une fonction utilisant des pointeurs génériques comme ceci :

void swap( void * a , void * b)
{
    void * tmp;
    tmp = a;
    a = b;
    b = tmp;
}

Supposons que nous effectuons le petit test suivant :

int a = 3;
int b = 2;
swap( &a, &b );
printf( "a = %d , b = %d" , a , b );

Nous aurions en sortie ceci :

a = 3 , b = 2

Ceci est tout à fait prévisible et nous avons été un peu vite en besogne pour l'écriture de notre fonction d'échange. En effet, il faut toujours avoir à l'esprit que la langage C effectue ce qu'on appelle du passage par copie. Ceci signifie donc que les arguments que l'on passe à une fonction sont copiés. Ainsi, pour notre fonction, ce ne sont pas les variables a et b que nous échangeons mais leur copie. Si vous avez déjà effectué une fonction qui doit échanger deux entiers, vous avez sûrement procédé ainsi :

void swap_int( int * a, int * b)
{
    int tmp;
    tmp = *a;
    *a = *b;
    *b = tmp;
}

Pourquoi ne pas faire la même chose avec notre fonction d'échange générique ? Et bien parce que nous ne le pouvons pas ! En effet, si nous échangeons vulgairement le type int par le type void, nous allons nous retrouver avec cette écriture :

void tmp = *a;

Or comme nous l'avons dit plus tôt, le type void ne permet pas de stocker quoi que se soit. De plus, l'opération de déréférencement de a provoquerait aussi une erreur. En effet, il n'est pas possible de déréférencer un pointeur générique. Ceci s'explique aussi par le fait que le pointeur générique ne sait pas vers quel type de variable son contenu pointe. Par conséquent, le déréférencer voudrait dire que l'on aurait une variable dont l'espace de stockage ne serait pas défini. Pour résoudre nos problèmes (passage par valeur et déréférencement impossible), il faut ruser et passer par un niveau de pointeur supérieur. Ainsi notre fonction d'échange correcte est celle ci :

void swap( void * d1 , void * d2 )
{
    void * tmp;
    tmp = *(void **)d1;
    *(void **)d1 = *(void **)d2;
    *(void **)d2 = tmp;
}

La fonction réalise un échange de pointeurs, d1 pointe vers ce qui était pointé par d2 et d2 pointe vers ce qui était pointé par d1. Cependant, cela ne résoud toujours pas notre problème. En effet nous n'échangeons que des pointeurs et pas le contenu des variables. Afin d'échanger correctement le contenu des variables, il faut concrètement copier l'espace mémoire qui se situe aux adresses données. Or comme les pointeurs génériques ne connaissent pas la taille occupée en mémoire des objets vers lesquels il pointe, il faut explicitement passer cette taille en paramètre de notre fonction d'échange. Finalement notre fonction d'échange générique est la suivante:

int swap (void *const a, void *const b, size_t size)
{
   void *const temp = malloc(size);

   if ( temp == NULL )
     return EXIT_FAILURE;

   memcpy(temp, a, size);
   memcpy(a, b, size);
   memcpy(b, temp, size);

   free(temp);

  return EXIT_SUCCESS;
}

Cette fonction va renvoyer la constante EXIT_SUCCESS si l'échange a pu avoir lieu, sinon, elle va renvoyer EXIT_FAILURE.

Il existe aussi un autre problème liée au pointeur générique, en effet, comme il est générique, il ne connaît pas l'espace mémoire des objets vers lesquels il pointe. Ceci entraîne donc un déréférencement impossible mais aussi des calculs d'adresses impossibles. Il ne sera donc pas possible d'effectuer des opérations de ce genre :

void * ptr;
/* ... On initialise ptr ... */
printf("%p", ptr + 1);

On ne peut pas le faire encore une fois parce que le pointeur générique ne connaît rien de ce vers quoi il pointe, y compris l'espace mémoire de la variable vers laquelle il pointe.

III. Parcours de tableaux génériques

On effectue souvent des parcours de tableaux de tous types, on peut donc se demander s'il n'est pas possible de réaliser une fonction qui puisse effectuer un parcours de tableau de manière générique. Comme toute fonction de parcours sur un tableau dynamique, nous devons passer à la fonction le nombre d'éléments à parcourir. Nous devons en plus de ce nombre passer la taille des éléments constituant le tableau. Nous avons vu que le pointeur générique ne permettait pas d'obtenir cette information, il faut donc la demander. Notre fonction aura donc pour signature :

void parcours( void * data , size_t nb_elt , size_t size);

Intuitivement, nous pourrions faire ceci :

void parcours( void * data , size_t nb_elt , size_t size )
{
    size_t i;
    for( i = 0 ; i < nb_elt ; ++i )
    {
        printf("%p\n", data + (i * size) );
    }
}

Mais nous avons vu que le calcul d'adresse ne pouvait pas être effectué sur un pointeur générique, il faut donc passer par un autre pointeur. IL faut donc un pointeur sur une variable dont l'espace de stockage soit le byte, c'est à dire la plus petite unité adressable par un programme en langage C. Ce type est tout naturellement le type char. On pourra donc effectuer un transtypage du pointeur générique vers un pointeur de caractère, ainsi nous pourrons effectuer des calculs d'adresse, et donc se déplacer de i*size bytes pour pouvoir pointer vers la i-ème case du tableau que l'on veut parcourir. Ainsi notre fonction devient alors :

void parcours( void * data , size_t nb_elt , size_t size )
{
    size_t i;
    for( i = 0 ; i < nb_elt ; ++i )
    {
        printf("%p\n", ((char *)data) + (i * size) );
    }
}

Afin de rendre plus lisible le code, il est préférable d'effectuer la conversion de type dès le début de la fonction.

void parcours( void * vdata , size_t nb_elt , size_t size )
{
    size_t i;
    char * data = vdata;
    for( i = 0 ; i < nb_elt ; ++i )
    {
        printf("%p\n", data + (i * size) );
    }
}

Ainsi on gagne en visibilité et le calcul d'adresse apparaît comme plus naturel. On notera également qu'à l'instar de ce qui est effectué avec la fonction malloc, il n'est pas nécessaire d'effectuer un transtypage explicite. Nous avons donc une fonction de parcours qui peut être utilisée sur n'importe quel type de tableau.

Pour augmenter la généricité, nous pouvons passer en paramètre de la fonction, un pointeur de fonction qui puisse réaliser des opérations sur le tableau. C'est exactement ce qui est fait avec la fonction qsort de la bibliothèque standard du C. Pour rappel sa signature est la suivante :

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

L'exemple suivant montre comment utiliser un pointeur de fonction pour afficher les éléments d'un tableau :

#include <stdlib.h>
#include <stdio.h>
void affd(const void *pva)
{
    const double *pa = pva;
    printf("%f\n",*pa);
}

void affi(const void *pva)
{
    const int *pa = pva;
    printf("%d\n",*pa);
}

void parcours( void * pvdata , size_t nb_elt , size_t size , void (*fct)(const void*))
{
    size_t i;
    char *data = pvdata;
    for( i = 0 ; i < nb_elt ; ++i )
    {
        fct(data+i*size);
    }
}

int main( void )
{
    int tabi[5] = {1,2,3,4,5};
    double tabd[5] = {1.23,2.34,3.45,4.56,5.67};

    parcours(tabi, sizeof(tabi)/sizeof(*tabi), sizeof(*tabi), affi);
    parcours(tabd, sizeof(tabd)/sizeof(*tabd), sizeof(*tabd), affd);

    return EXIT_SUCCESS;
}

Ainsi, avec la même fonction, nous pouvons afficher un tableau d'entiers comme un tableau de réels. On peut faire bien plus qu'afficher des variables de types atomiques, il suffit simplement de définir une fonction qui effectue l'affichage d'un objet et la passer en paramètre à la fonction parcours.


Les nouveautés du C99

Table des matières

  • Historique
  • I. Introduction
  • II. Préprocesseur
  • III. Syntaxe
  • IV. Sémantique
  • V. La bibliothèque standard
  • VI. Conclusion
  • VII. Références

Au travers de cet article, je vous propose un tour d'horizon des nouveautés du C99.

class="articleBody" itemprop="articleBody">

Historique

4 Novembre 2006
  • Clarification de la signification des pragma
  • Précision sur l'environnement en virgule flottante
  • Ajout des nouveaux fichiers d'en-tête
  • Ajout de références

I. Introduction

A l'heure actuelle, deux normes cohabitent : il y a celle publiée en 1989 par l'ANSI(1), reprise en 1990 par l'ISO(2) connue respectivement sous le nom de C89 et C90 (ce sont les mêmes) et une seconde norme, qui regroupe les différents drafts apparues depuis la dernière norme, publiée en 1999 par ISO connue sous le nom de C99 (et C2k pour la version ANSI). Cette dernière a pour but de minimiser les incompatibilités entre le C et le C++ (sans pour autant en faire un seul et même langage). Voici donc la liste des nouveautés et des modifications apportées par la norme C99 par rapport au C90.

II. Préprocesseur

II-A. La macro __STDC_VERSION__

Cette macro permet de connaître la norme utilisée :

  • En C89 elle n'est pas définie
  • En C94 elle vaut 199409L
  • Et maintenant, en C99, 199901L.

II-B. Macros à nombre variable d'arguments

Cette fonctionnalité permet de définir des macros dont le nombre des arguments peut être différent d'un appel à l'autre. La notation utilisée est la même que pour les fonctions ('...') et la liste est récupérée grâce à la macro __VA_ARGS__ :

#define print_error(s, ...) fprintf (stderr, (s), __VA_ARGS__)

II-C. Les commentaires mono-ligne

Les commentaires "à la C++", c'est à dire commençant par // et se terminant à la fin de la ligne viennent s'ajouter aux commentaires classiques (/* */).

II-D. La directive #line

Cette directive du préproceseur permet de modifier le numéro de la ligne courante (et optionnellement le nom du fichier). La valeur utilisée était limitée à 215-1 en C90 et passe maintenant à 231-1.

II-E. La directive #pragma

La directive #pragma permet de contrôler le comportement du compilateur. La norme C99 définie trois pragma standards :

  • FP_CONTRACT : optimisation des expressions flottantes
  • FENV_ACCESS : notre programme accède aux paramètres du FPU (Floating Point Unit ou unité de calcul flottant), ceci se fait à l'aide des fonctions déclarées dans fenv.h. Le compilateur doit donc respecter la norme pour les opérations sur les nombres flottant
  • CX_LIMITED_RANGE : dans le cas d'opérations sûres avec les nombres complexes (c'est à dire sans risque de débordement de capacités), vous pouvez le signaler afin d'accélérer le traitement en activant ce pragma.

Chacune de ces fonctionnalités peuvent être activée (ON), désactivée (OFF) ou remis à son état par défaut (DEFAULT). Par exemple, voici comment activer les optimisations des expressions flottantes :

#pragma STDC FP_CONTRACT ON

Chaque compilateur propose ces propres directives pragma. Si le compilateur rencontre une directive inconnue, la norme prévoie que celle-ci soit ignorée, cependant il est déconseillé d'utiliser des directives non standard car selon les compilateurs leur rôle peut être différent.

A la place de la directive #pragma, il est possible d'utiliser l'opérateur unaire _Pragma.

III. Syntaxe

III-A. Les nouveaux mots réservés

Le C99 ajoute cinq mots réservés au langage :

  • restrict
  • inline
  • _Complex
  • _Imaginary
  • _Bool

III-B. Séquences d'échappement à deux caractères

Les séquences d'échappement à deux caractères permettent de remplacer certains caractères spéciaux qui peuvent être absent du clavier (comme les séquences d'échappement à trois caractères). Avant tout traitement, les lexèmes <:, :>, <%, %>, %: et %:%: sont respectivement remplacés par [, ], {, }, # et ##.

III-C. Mélange déclaration/code

En C90, les déclarations doivent être faites au début d'un bloc, alors qu'en C99 il est tout à fait possible d'écrire :

int main (void)
{
   printf ("Debut du code\n");
   int i = 10;
   printf ("i vaut %d\n", i);
   return 0;
}

Cependant ce genre de mélange n'est pas recommandé afin de préserver la lisibilité du code.
Cette fonctionnalité entraîne la possibilité de créer une variable de boucle temporaire pour l'instruction for :

for (int i = 0; i < 10; i++)
{
    /* variable i utilisable */
}
/* variable i inexistante */

Est équivalent à :

{
   int i;

   for (i = 0; i < 10; i++)
   {
      /* variable i utilisable */
   }
}
/* variable i inexistante */

Par conséquent, la variable i n'existera plus à la sortie de la boucle.

III-D. Initialisation des membres d'une structure

Il est possible d'initialiser les membres d'une structure, lors de sa déclaration, en utilisant leurs noms :

struct T
{
   int a;
   int b;
};

struct T s1 = {.a = 0, .b = 1};
/* ou dans le cas d'un tableau de structure : */
struct T s2[] = {[0].a = 0, [0].b = 0, [1].a = 1, [1].b = 1};

De plus, pour les structures de type auto, il est possible d'initialiser les membres avec des valeurs non connues au moment de la compilation :

int i = 0;
struct T s1 = {.a = i, .b = i+2};

IV. Sémantique

IV-A. Les fonctions en ligne

Le mot-clé inline est un nouvel attribut pour les fonctions qui ont pour but de remplacer le système de macro défini grâce à la commande du préprocesseur #define. Pour illustrer l'avantage de cette méthode par rapport aux macros, voici l'exemple classique du calcul du carré d'un nombre :

#define carre(a) ((a)*(a))

Si par malheur vous oubliez les parenthèses autour de la variable a, la macro peut produire un résultat inattendu, il faut aussi faire attention au effets de bord puisque la variable a est évaluée deux fois. Maintenant avec le mécanisme de fonction en ligne :

inline int carre (int a)
{
   return a*a;
}

int main (void)
{
   int b = carre (5);

   printf ("Le carre de 5 est %d\n", b);
   return 0;
}

Ceci supprime les dangers liés à l'utilisation de l'instruction #define. Les fonctions en ligne doivent être définies aux mêmes endroits que les macros classiques, ce qui peut alourdir les fichiers d'en-tête et surtout recopie la fonction dans plusieurs fichiers sources : pour éviter de se retrouver avec plusieurs fonctions globales portant le même nom, il est conseillé de toujours utiliser le mot-clé inline conjointement avec le mot-clé static (même dans un fichier d'en-tête).

L'attribut inline est une information d'optimisation pour le compilateur, si la mise en ligne n'est pas possible, le compilateur peut ignorer cet attribut.

IV-B. long long

Le nouveau type d'entier long long (signé ou non) est représenté sur au moins 64 bits. Il s'accompagne des notation LL (ou ll) et ULL (ou ull).

IV-C. __func__

La variable __func__(3) contient le nom de la fonction courante. Elle est déclarée ainsi :

static const char __func__[];

IV-D. Type de retour implicite

En C90, en l'absence de type de retour, le compilateur considère que cette fonction retourne un int, en C99 ce n'est plus possible : le type de retour doit être spécifié.

IV-E. Les pointeurs restreints

Le mot-clé restrict permet de spécifier qu'un pointeur est le seul à pointer sur une zone mémoire. Cela permet au compilateur d'opérer certaines optimisations. Par exemple, la fonction memcpy qui est utilisée pour copier deux zones de mémoires qui ne se chevauchent pas peut avoir comme prototype, en C99 :

void *memcpy (void *restrict dst, const void *restrict src, size_t size);

Le mot-clé ce place entre le caractère * et le nom du pointeur.

Comme pour les fonctions en ligne, cette directive peut être ignorée par le compilateur.

IV-F. Les tableaux de taille variable

Voilà une fonctionnalité qui va réjouir les réfractaires à l'utilisation des fonctions d'allocation dynamique. En effet, en C90 pour déclarer un tableau, sa taille doit être connue à la compilation. Le C99 propose un mécanisme nommé VLA (Variable-Length Arrays), qui permet de créer des tableaux dont la taille n'est connue qu'à l'exécution. Voici un exemple :

void foo (int n)
{
  int tab[n];
  ...
}

Comme toutes variables automatiques, elles sont détruites à la fin du bloc dans lequel elles sont déclarées (plus de fuites mémoires avec ce système), en contre partie l'allocation dynamique reste indispensable pour retourner l'adresse d'une zone mémoire.

On peut aussi déclarer un tableau de taille variable dans le prototype d'une fonction :

void foo (int n, int a[n]) { ... }

Il faut que la variable n soit définie avant, ce qui interdit d'écrire ceci :

void foo (int a[n], int n) { ... }

La déclaration d'une telle fonction peut s'écrire :

/* Declaration complete */
void foo (int n, int a[n]);
/* Declaration partielle */
void foo (int n, int a[*]);

Notez l'utilisation du caractère '*' pour signaler un tableau de taille variable.

Malgré le warning de gcc, le code fonctionne comme prévu.
Ce mécanisme très puissant a tout de même certaines limitations :

  • Les tableaux de tailles variables sont limités au fichier, il n'est pas possible d'utiliser le mot-clé extern :
    extern int n;
int a[n]; /* n est declaree extern : utilisation interdite */
extern int b[n]; /* utilisation interdite */
  • Il n'est pas possible de créer de tel tableau static. Par contre il est possible de déclarer static un pointeur sur VLA :
    int b[10];

int foo (int n)
{
  static int a[n]; /* Interdit */
  static (*p_b)[n] = &b; /* Authorise si n == 10 */
}

    Déclarer un pointeur dur un VLA d'une taille différente de celle autorisée ne stoppe pas l'exécution du programme (on pourrait s'attendre à la levée d'une exception ou quelque chose approchant), mais bien sur ce genre de manipulation est très risquée et est à classer dans les comportements indéfinis.

  • Il n'est pas possible de déclarer un tableau de taille variable dans une structure ou une union
  • Il n'est pas possible d'effectuer un saut dans un bloc qui contient une déclaration de ce type de tableau, par contre sortir de ce bloque est autorisé :
    goto start; /* Interdit */
{
  int n = 10;
  int a[n];

start :
  /* ... */
  goto end; /* Authorise */
}
end :
/* ... */

IV-G. Type de tableau incomplet

Lors de la déclaration d'une structure, il est possible de ne pas spécifier la taille d'un tableau, s'il s'agit du dernier membre de la structure :

struct incomplet
{
  int n;
  int tab[];
};

Cela permet de faire de belles choses comme ceci :

#include <stdio.h>
#include <stdlib.h>

struct incomplet
{
  int n;
  int tab[];
};

int main (void)
{
  struct incomplet *a = NULL;

  a = malloc (sizeof (*a) + 3 * sizeof (int));
  if (a)
  {
    a->n = 3;
    for (int i = 0; i < a->n; i++)
    {
      a->tab[i] = i;
    }
    printf ("a->tab = { ");
    for (int i = 0; i < a->n; i++)
    {
      printf ("%d, ", a->tab[i]);
    }
    printf (" };\n");
    free (a), a = NULL;
  }

  struct incomplet *b = NULL;

  b = malloc (sizeof (*b) + 5 * sizeof (int));
  if (b)
  {
    b->n = 5;
    for (int i = 0; i < b->n; i++)
    {
      b->tab[i] = i;
    }
    printf ("b->tab = { ");
    for (int i = 0; i < b->n; i++)
    {
      printf ("%d, ", b->tab[i]);
    }
    printf (" };\n");
    free (b), b = NULL;
  }
  return 0;
}

gege2061@debian:~/c99$ gcc -Wall -std=c99 main.c && ./a.out
a->tab = { 0, 1, 2,  };
b->tab = { 0, 1, 2, 3, 4,  };

Cela évite d'utiliser un pointeur pour tab et d'avoir une allocation supplémentaire à faire. Hélas il n'est pas possible de faire :

Sélectionnez
struct incomplet a = {3, 0, 1, 2};
struct incomplet b = {5, 0, 1, 2, 3, 4};

V. La bibliothèque standard

V-A. Les nouveaux fichiers d'en-tête

Un enrichissement de la bibliothèque standard, s'accompagne logiquement de nouveaux fichiers d'en-tête :

  • complex.h
  • fenv.h
  • inttypes.h
  • iso646.h
  • stdbool.h
  • stdint.h
  • tgmath.h
  • wchar.h
  • wctype.h

V-B. Les types entiers étendus

En C, la taille des types de variables n'est pas définie par la norme, seul une taille minimale est garantie. Le C99, dans le fichier d'en-tête stdint.h propose des types dont la taille est connue :

Types | Description
intN_t | Entier signé de N (8, 16, 32 ou 64(4)) bits
uintN_t | Entier non signé de N (8, 16, 32 ou 64) bits
intmax_t | Entier signé de taille maximale
uintmax_t | Entier non signé de taille maximale
int_leastN_t | Entier signé d'au moins N (8, 16, 32 ou 64)bits
uint_leastN_t | Entier non signé d'au moins N (8, 16, 32 ou 64) bits
int_fastN_t | Entier rapide (5) signé d'au moins N (8, 16, 32 ou 64) bits
uint_fastN_t | Entier rapide non signé d'au moins N (8, 16, 32 ou 64) bits

Ces types sont notés comme optionnels dans la norme, par conséquent leur portabilité est réduite.

V-C. Les caractères étendus

En C90 il n'était pas possible d'utiliser de caractères spécifiques d'une langue (par exemple, les caractères accentués pour le français) et ce, même dans les commentaires. Pour pallier ce problème, la norme C99 propose un nouveau type de caractères étendus nommés wchar_t (pour wide char) défini dans wchar.h. Pour convertir un caractère étendu en entier, le type int peut ne pas être suffisant, à la place, il faut utiliser le type wint_t.
Toutes les fonctions de manipulation des chaînes de caractères (de la forme str*) sont reprises sous la forme wcs* (par exemple wcslen, wcscmp ou encore wcscpy) ainsi que les fonctions wprintf et wscanf pour l'affichage et la saisie formatée de chaîne de caractères étendues.

V-D. va_copy

Le fichier d'en-tête stdarg.h contient une nouvelle fonction va_copy qui permet de copier un objet de type va_list.

V-E. Le type booléen

Un booléen est un nouveau type de variable qui peut prendre uniquement la valeur vrai ou faux. La définition du type bool est faite dans le fichier d'en-tête stdbool.h et contient les définitions suivantes :

  • bool : le type booléen
  • true : la valeur vrai
  • false : la valeur faux
  • __bool_true_false_are_defined : si cette macro est définie, les valeurs bool, true et false le sont aussi.

La définition du type bool a été placée dans un fichier d'en-tête pour garantir la compatibilité avec les anciens codes (il n'est pas rare de voir un code contenant déjà une définition de bool). Si, pour une raison quelconque, vous ne souhaitez pas inclure stdbool.h, le langage contient le type _Bool qui peut prendre les valeurs 0 (faux) et 1 (vrai).

V-F. Les nombres complexes

Les nombres complexes font leurs entrées dans le langage C grâce au type complex défini dans complex.h. Comme il existe trois représentations pour les nombres réels, il existe trois types de nombres complexes :

float complex
double complex
long double complex

Pour construire un nombre complexe, il suffit d'additionner sa partie réelle et sa partie imaginaire :

double partie_reelle = 2.0;
double partie_imaginaire = 3.0;
double complex z = partie_reelle + partie_imaginaire * I;

La macro I permet de construire la partie imaginaire.
Pour retrouver les parties réelles et imaginaires d'un complexe, on peut utiliser, respectivement les fonctions creal et cimag définies pour les trois type de complexes :

float crealf (float complex z);
double creal (double complex z);
long double creall (long double complex z);

float cimagf (float complex z);
double cimag (double complex z);
long double cimagl (long double complex z);

La bibliothèque standard du C99 propose aussi des fonctions pour calculer le module (cabs*), l'argument (carg*) et la représentation selon la projection sur la sphère de Riemann (cproj*) d'un nombre complexe.

V-G. Les nombres à virgules flottantes

C'est sûrement dans ce domaine que les nouveautés sont les plus nombreuses, puisque le C99 introduit la notion d'environnement virgule flottante qui permet une gestion améliorée des erreurs du système de calcul en nombres flottants.

V-G-1. Dépassement de capacité de la valeur de retour

En plus de la valeur HUGE_VAL, retournée lorsque le type de retour n'a pas la capacité nécessaire pour contenir le résultat, la norme C99 ajoute les valeurs HUGE_VALF (de type float) et HUGE_VALL (de type long double).

V-G-2. Le controle des arrondis

Le type d'arrondi effectué sur les nombres à virgules flottantes est représenté par les constantes définies dans fenv.h :

  • FE_TONEARREST : au plus proche (valeur par défaut)
  • FE_UPWARD : vers le haut
  • FE_DOWNWARD : vers le bas
  • FE_TOWARDZERO : vers zéro (vers le bas pour les nombres positifs, vers le haut pour les nombres négatifs).

Il est possible de connaître le mode utilisé grâce à la fonction :

int fegetround (void);

Et de le modifier grâce à :

int fesetround (int mode);

En cas de succès, cette fonction retourne 0.

V-G-3. Les exceptions

En cas d'erreur lors d'une opération sur les nombres en virgules flottantes, une exception est levée, elle peut être traitée par le système (par exemple en envoyant un signal SIGFPE pour les systèmes POSIX). Cependant, un signal n'est pas toujours envoyé, dans ce cas c'est au programmeur de les tester.
Il existe cinq exceptions, représentées par des macros :

  • FE_DIVBYZERO : division par zéro
  • FE_OVERFLOW : valeur trop grande
  • FE_UNDERFLOW : valeur trop petite
  • FE_INEXACT : résultat inexacte
  • FE_INVALID : opération indéfinie
  • FE_ALL_EXCEPT : représente l'ensemble des exceptions.

Vous pouvez tester la levée d'une exception avec la fonction :

int fetestexcept (int exceptions);

Où exceptions peut être une combinaison des macros précédentes (grâce à un ou binaire |) et la fonction retourne les exceptions réellement levées parmis celles passées en argument (ou zéro si aucune n'est levée).
Vous pouvez lever une ou plusieurs exceptions grâce à :

void feraiseexcept (int exceptions);

Et inversement, baisser les drapeaux d'exceptions avec :

void feclearexcept (int exceptions);

Et enfin, vous pouvez sauvegarder et restaurer l'état d'un ou plusieurs drapeaux :

Sélectionnez
void fegetexceptflag (fexcept_t *svg, int execptions);
void fesetexceptflag (const fexcept_t *svg, int execptions);

V-G-4. Manipulation de l'environnement

Vous pouvez enregistrer et sauvegarder la configuration de l'environnement avec la fonction :

void fegetenv (fenv_t *ecf);
void fesetenv (const fenv_t *ecf);

La fonction fesetenv peut pendre en argument la valeur FE_DFL_ENV qui permet de restaurer l'état initial de l'environnement.

void feupdateenv (const fenv_t *ecf);

Cette fonction restaure l'environnement en préservant les drapeaux d'exception.

void feholdexcept (fenv_t *ecf);

feholdexcept désactive les exceptions en sauvegardant les paramètres actuels.

VI. Conclusion

Voilà, notre petit tour des nouveautés du C99 est terminé ! Je n'ai fait que vous présenter les nouveautés les plus importantes et sans approfondir, volontairement, certains points. Par exemple les nombres flottants ou les caractères étendus nécessiteraient un article entier, si le coeur vous en dit : http://club.developpez.com/redaction/ ;).
Cependant à l'heure actuelle, il n'est pas recommandé d'utiliser les spécificités de cette nouvelle norme puisqu'elle n'est pas encore intégralement implémentée dans tous les compilateurs (par exemple pour gcc : Status of C99 features in GCC). Il existe tout de même certains compilateurs conformes à la norme C99(6) :

  • Dinkum Unabridged Library (Dinkumware, Ltd)
  • EDG C/C++ Compiler (Edison Design Group)
  • Visual Age Compiler (IBM)
  • LMPCC C99 Compiler (Lund Multiprocessor Compiler Company, AB)
  • Sun Studio 9 ( Sun Microsystems)

VII. Références

Si vous souhaitez en savoir plus sur ce sujet, vous pouvez télécharger gratuitement la dernière version de la norme au format pdf : n1124.pdf.
Je vous conseille aussi la lecture de deux très bons livres qui abordent le sujet : méthodologie de la programmation en C et C précis et concis.

(1)
American National Standard Institut
(2)
International Organization for Standardization
(3)
il s'agit d'une variable et non d'une macro comme __FILE__ puisque le préprocesseur n'a pas la notion de fonction
(4)
Cette dernière taille n'est définie que pour les processeurs 64 bits
(5)
la taille est choisie afin d'augmenter la rapidité des opérations
(6)
Certifié conforme par Perennial


De la fonction scanf

Scanf démythifiée

Table des matières

  • Avant-Propos
  • Introduction
  • Description de la fonction scanf
  • Problèmes à l'utilisation, et solutions apportées
  • Utilisation courante de scanf
  • Conclusion

Présentation des erreurs courantes induites par l'utilisation de scanf et des moyens de s'en prévenir.

Avant-Propos

Avant toute chose, l'utilisation de la fonction scanf suppose une bonne connaissance de la mécanique des pointeurs, sur lesquels elle repose. Je ne reviendrai pas sur cette mécanique, de nombreux cours (notamment sur l'Internet) le faisant de manière approfondie.

Ces prérequis constituent un des points pour lesquels la fonction scanf ne doit pas être présentée trop tôt aux débutants, qui maîtrisent mal les pointeurs, sources de "segmentation fault" bien connus des développeurs C... ;-)

Introduction

L'article suivant a été écrit dans le but de démythifier la fonction scanf (et, à travers elle, les fonctions *scanf).
Cette fonction, simple à appréhender dans ses services rendus, est en effet une des premières fonctions de saisie présentée aux débutants.
Malheureusement, si son concept est trivial, le comportement sous-jacent l'est beaucoup moins, provoquant nombre d'erreurs dans les programmes de ces mêmes débutants.
D'autre part, l'usage sécurisé de cette fonction entrainant une surcharge non négligeable du code, les personnes expérimentées lui préfèrent le plus souvent fgets() et les fonctions de conversions adéquates, et finissent par négliger, voire oublier l'existence des fonctions *scanf() .

Description de la fonction scanf

Etudions les paramètres utilisés par scanf lors de son appel :

#include <stdio.h>

int scanf ( const char *format, ...);

Cette fonction de la librairie standard prend en paramètre une chaîne de caractères décrivant le format du flux entrant "attendu", ainsi qu'une liste (vide ou non), de pointeurs valides (c'est à dire pointant sur une zone mémoire allouée, et de taille à contenir le résultat de la conversion).

Note :
le 'f' de scanf indique que l'entrée analysée est supposée formatée, c'est à dire générée par des fonctions comme *printf (ou par d'autres moyens, mais avec un comportement similaire).
C'est de ce 'f' que viennent tous les problèmes que l'on puisse rencontrer avec cette fonction : en effet, on oublie trop souvent, lorsqu'on l'utilise, qu'elle est éprouvée par un utilisateur lambda, et que rien ne l'empêche de faire des saisies "non formatées", provoquant l'échec de la fonction scanf. Nous reviendrons plus tard sur ce point.

La chaîne "format" peut contenir, des caractères alphanumériques propres à l'utilisation du développeur (comme "path_file", "device", etc...) : pratique quand on cherche à scanner un fichier de configuration, avec des entrées bien précises ( avec fscanf ). Elle peut de même contenir (c'est le plus intéressant !) des spécificateurs de format permettant de prélever sur l'entrée standard, et selon un format défini à l'avance, des éléments susceptibles d'influer sur l'exécution du programme.

Exemple :
Utilisation de fscanf (qui a exactement le même comportement que la fonction scanf) pour l'analyse d'un fichier de configuration. On voit que l'on peut mélanger des caractères "banals" et des spécificateurs (pour par exemple récupérer le numéro d'un device) :

int i;
fscanf(config_file, "device_number = %d", &i);

Ces spécificateurs sont précédés du signe '%'. Ils sont par ailleurs constitués de plusieurs drapeaux (certains optionnels), affinant les spécifications sur l'analyse du flux entrant.

Note :
Si le développeur souhaite intégrer dans sa chaîne le signe '%' sans pour autant indiquer une conversion, il suffit de le doubler pour qu'il perde sa signification.
Ainsi, dans la chaîne "hello%%world", le second '%' ne sera pas interprété comme annonçant le début d'une conversion, puisqu'il est inhibé par le premier '%'.

La liste suivante des drapeaux constituant les spécificateurs de format n'a pas pour but d'être exhaustive, mais d'en présenter les principaux, avec leur définition et leur utilisation. Pour plus d'informations, le meilleur document (plus accessible que la norme ISO/IEC 9899:1990):

man scanf

Un spécificateur se compose d'entêtes :

% | Le signe '%', introduisant une conversion
* | Le signe '*', précisant que si la conversion est réussie, l'objet de cette conversion sera ignoré : il est inutile d'associer un pointeur au résultat de cette conversion. De manière optionnelle, on peut aussi préciser (entre le signe '%' et la conversion) un champ indiquant la longueur de la saisie attendue. C'est un entier décimal. Sa valeur par défaut est "l'infini", c'est a dire que scanf cherchera a scanner le plus possible d'éléments remplissant la condition voulue.

Ainsi qu'une combinaison des drapeaux suivants :

d | entier décimal signé. Le pointeur associé doit être un pointeur sur "int"
i | entier signé. Le pointeur associé doit être un pointeur sur "int". L'entier est lu en base 16 s'il commence par '0x' ou '0X' ; s'il commence par un '0', il est lu en base 8 ; dans tous les autres cas, il est lu en base 10
o | entier octal. Le pointeur associé doit être un pointeur sur "unsigned int"
u | entier décimal, à ceci près que le pointeur associé doit être un pointeur sur un "unsigned int"
x,X | entier hexadécimal signé. Pointeur sur "unsigned int" associé

Note :
Pour les conversions "dioux", si l'on précède les formateurs d'un 'l', alors le pointeur associé pointe sur un "long int" ; un 'h' indique un pointeur sur "short int" ; un "hh" sur "char" ; et enfin, "ll" un pointeur sur "long long int".

e,f,g Ces formateurs permettent de récupérer des nombres flottants. Leur format est le même que celui utilisé (et validé) par la fonction strtod, dont je me permet de citer le manuel quant à la représentation attendue (Attention, la source étant le manuel Unix, il pourrait y avoir certains détails d'implantation propre à ce système. Il s'agit là d'une indication, pour plus de détails, je vous invite à consulter la norme ISO/IEC 9899:1990 (C90) ) :

La (portion initiale de la) chaîne attendue est de la forme suivante :
Des espaces éventuels en tête, ou des caractères interprétés comme tels par isspace(3)
Un éventuel signe plus (``+'') ou moins (``-'')
Un nombre décimal ou hexadécimal, ou un infini, ou encore un non-nombre NAN (not-a-number).
Un nombre décimal consiste en une séquence non-vide de chiffres décimaux, pouvant contenir un point décimal (dépendant de la localisa- tion, mais habituellement ``.''), suivi éventuellement d'un exposant décimal. Ce dernier est constitué d'un ``E'' ou ``e'' suivi éventuellement du signe plus ou du signe moins, puis une séquence non- vide de de chiffres décimaux, indiquant la multiplication par une puis- sance de 10.
Un nombre hexadécimal commence ``0x'' ou ``0X'', suivi d'une séquence non-vide de chiffres hexadécimaux, pouvant contenir un point décimal suivi éventuellement d'un exposant binaire. Ce dernier est constitué d'un ``P'' ou ``p'' suivi éventuellement du signe plus ou du signe moins, puis une séquence non-vide de de chiffres décimaux, indiquant la multiplication par une puissance de 2. Il ne peut y avoir plus d'un point ou d'un exposant binaire.
Un infini est soit ``INF'', soit ``INFINITY'', sans tenir compte des différences entre majuscules et minuscules.
Un non-nombre est ``NAN'' (sans différences majuscules/minuscules) suivi éventuellement par `(', une séquence de caractères, et `)'. La chaîne de caractères indique le type de non-nombre de manière dépendant de l'implémentation.

A noter que le pointeur associé doit être un pointeur sur "float" (sauf si on positionne le drapeau 'l' (indiquant un double), ou 'L' (indiquant un 'long double')
s séquence de caractères différents d'un caractère d'espacement.
Le pointeur associé doit être un pointeur sur char.
L'espace alloué à la réception de la chaîne saisie doit être assez grand pour contenir à la fois la séquence et le caractère de terminaison de chaîne ('\0', qui est ajouté automatiquement)
La séquence s'arrête lorsque scanf rencontre un caractère d'espacement, ou bien lorsque la longueur précisée (champ de taille vu plus haut) a été atteinte
Note :
Un caractère est considéré comme d'espacement s'il fait partie de l'ensemble suivant :
' ' (espace)
'\t' (tabulation)
'\n' (fin de ligne)
....
c séquence de caractères. Par défaut, 1 seul caractère, à moins qu'un champ taille n'ait été précisé. Le pointeur associé est un pointeur sur char.
Dans le cas d'une séquence de plusieurs caractères, l'espace alloué doit être suffisant pour contenir tous les caractères.
Contrairement au formateur 's', aucun caractère de terminaison de chaîne n'est ajouté
[ permet d'introduire des expressions rationnelles.
Nous les verrons plus en détail par la suite. Le pointeur associe est un pointeur sur "char".
L'espace alloué doit être suffisant pour contenir de surcroît le caractère de terminaison de chaîne (ajouté automatiquement).
Est associé au caractère ']' fermant le set d'expression rationnelle

Note :
Le caractère 'virgule' utilisé par les formateurs de flottants est défini suivant les locales du système.
Ainsi, cela peut être ',' ou '.' ou autre... Attention à la portabilité !

Problèmes à l'utilisation, et solutions apportées

Comportement de la fonction scanf (particulièrement en cas d'erreur)

La fonction applique le filtre décrivant le format (la chaîne de caractère), sur le flux entrant.
Pour chaque conversion réussie, scanf place l'objet converti dans l'espace pointé par le pointeur associé. Aucune vérification n'est faite quant à la validité de ces pointeurs, de même que, dans le cas de saisie de chaîne de caractères (ou de séquence de caractères), aucun contrôle sur le rapport entre les quantités "espace alloué" / "taille de la saisie" : si le programmeur ne fait pas ces contrôles lui-même, les risques de buffer overflow sont élevés.

C'est là, je crois, le point le plus important à comprendre dans le fonctionnement de la fonction scanf. Si au cours de l'analyse de la chaîne de format une des conversions échoue, alors la fonction scanf s'arrête brutalement : les conversions suivantes ne sont pas effectuées !! On peut donc avoir une partie des résultats attendus, mais pas tous !

Il est donc impératif de verifier la valeur retournée par scanf. Cette valeur n'est pas innocente, elle indique précisément le nombre d'affectations correctes. Il va sans dire que continuer l'exécution d'un programme en ayant une partie des variables non initialisées (celles non "remplies" par scanf suite a un échec de conversion) provoque un "undefined behaviour" (comportement indéfini) qui file de l'urticaire à n'importe quel développeur sérieux...

Comportement vis à vis des espaces

C'est là aussi une des subtilités du fonctionnement de scanf qu'il faut connaître, surtout si l'on souhaite en faire une utilisation poussée (i.e. sécurisée).

Tout d'abord, le cas particulier dont je vais vous parler concerne uniquement le cas du formateur '%c' (ainsi que celui des expressions rationnelles, mais le fonctionnement par rapport à ce cas particulier est le même...)

Par défaut, scanf supprime les espaces précédant ce qui peut convenir au formateur. Mais dans le cas du '%c', elle ne le fait pas, ce qui fait que les espaces peuvent "compter réellement" lorsque l'on a précisé une taille de saisie par exemple...
Le concept est peut être un peu nébuleux, mais en pratique, c'est très simple... ;-)

Imaginons que je veuille récupérer 10 caractères sur l'entrée standard.

Je déclare un tableau de taille suffisante (i.e. permettant de mettre un terminateur de chaîne "manuellement" : on a vu que dans le cas du '%c', un caractère '\0' final n'était pas ajouté par scanf, au contraire du '%s')

char tab[11] = {0}; /* en l'initialisant à zero, je met le
                '\0' final directement...*/

Je récupère les 10 caractères naïvement... Et j'affiche le résultat.

scanf("%10c", tab); /* recuperation */
printf("lu : %s", tab);

Et je fais la saisie suivante (entre guillemets qui ne font, eux, pas partie de la saisie)

      "azertyuiop" puis <Enter>

On obtient :

      "lu : azertyuiop"

Jusque là, tout va bien...
Mais imaginons un utilisateur farceur...

      "     azertyuiop"  puis Enter

Il y a cinq espaces avant la saisie qui nous intéresse..
Et on obtient :

      "lu : '     azert'"

Les blancs n'ont pas été enlevés. Et c'est gênant.

Remarque :
On voit bien, une fois de plus, que les formateurs ne sont pas à employer à la légère : leur utilisation génère des résultats complètement différents.
On ne peut donc intervertir '%c' et '%s' sans qu'il y ait de conséquences.

Rappel :

%s | [*] s'arrête au premier caractère "blanc" (sauf si l'on a précisé une longueur de champ, et que celle-ci est atteinte avant un blanc.) [*] supprime les espaces lors de l'analyse [*] ajoute un terminateur de chaîne (caractère '\0') de façon automatique.
%c | [*] lit par défaut un seul caractère [*] ne supprime pas les espaces "non utiles" en tête de saisie: ils comptent alors contre le champ taille que l'on a pu préciser. [*] n'ajoute pas de caractère final : il faut le faire manuellement si l'on souhaite travailler sur une chaîne valide.

Malgré cela, que faire pour passer outre ce comportement qui peut fausser les exécutions des programmes. Il "suffit" de faire précéder le formateur '%10c' d'un espace, pour dire explicitement à scanf de réduire les espaces en tête.
On doit donc mettre ' %10c'.
Vérifions :

scanf(" %10c", tab);

Saisie :

      '     azertyuiop'  (cinq espaces précédant la saisie éffective)

Donne :

      'lu : azertyuiop'

Les espaces ont biens été supprimés.

Note :
On voit souvent les débutants utiliser à tort et à travers les espaces dans les chaînes de formatage. On voit bien à travers cet exemple que leur présence n'est pas innocente, et peut influer sur le comportement de la fonction.
Vigilance donc dans l'utilisation abusive d'espaces, que ce soit dans la chaîne de formatage, ou lors de la prise en compte des saisies utilisateur !!

Utilisation courante de scanf

Saisie sécurisée d'une chaîne de caractère

Tout d'abord, il va sans dire que le paragraphe précédent n'a pas été écrit inutilement, et il faut bien avoir compris les subtilités d'espaces avec scanf. Tout peut être fait selon nos désirs, mais encore faut-il savoir clairement ce que l'on veut. Je considère donc tout ce qui a été dit précédemment comme acquis.

Importance du contrôle de la taille mémoire

En effet, le principal défaut d'une fonction comme scanf est de ranger directement en mémoire ce qui est prélevé sur le flux entrant là où le programmeur l'a décidé.
Mais cela pose problème en cas de saisie trop longue : c'est le fameux buffer overflow, qui peut être exploité à des fins nuisibles, que ce soit dirigé contre votre programme, ou pire, contre votre système (shellcodes, etc...)

Il devient donc impératif de contrôler la taille de cette saisie, de façon à éviter ces comportements litigieux.

Si vous avez été attentifs, vous avez sûrement noté quel outil va nous permettre d'assurer ces contrôles. Il s'agit bien sûr du flag "taille" que l'on peut glisser entre le signe '%' et le formateur indiquant la conversion.

Ainsi, lorsque vous allouez de la place en mémoire pour recevoir la chaîne de caractères (que ce soit statiquement ou dynamiquement), il vous faut conserver cette taille afin de la réutiliser dans ce champ taille, et ainsi sécuriser la saisie.

On peut procéder de plusieurs façons pour positionner la taille dans la chaîne de formatage.
Si vous avez déclaré un tableau de caractère de manière statique :

char tab[80];

La meilleure façon de faire dans ce cas là est de définir, via une macro, la longueur du tableau.
Ainsi, cela devient :

#define TAILLE 80
<...>
char tab[TAILLE+1] = {0}; /* +1 pour le caractere terminal */

Puis, on utilise une astuce du préprocesseur (il suffit de lire la doc de CPP pour la connaître ;-)

#define xstr(s) str(s)
#define str(s) #s

Ces macros nous permettent de transformer en chaîne de caractère des macros en les expansant d'abord.
On peut donc l'utiliser ainsi :

scanf("%"xstr(TAILLE)"s", tab);

Si on passe uniquement le préprocesseur sur ce morceau de code, on observe que cpp a transormé cette notation en :

scanf("%""80""s", tab);

Ce qui équivaut à :

scanf("%80s", tab);

On a donc fait une saisie de chaîne de caractère déjouant le piège du buffer overflow. La même astuce peut être utilisée pour le formateur '%c', ainsi que pour tout autre nécessitant un contrôle.

Cette façon de faire fonctionne bien en cas de définition statique de la taille de l'espace nécessaire au stockage de la chaîne de caractere. De façon dynamique, on peut aussi le faire, mais la mise en oeuvre est plus lourde.

Imaginons que la taille soit définie en cours d'exécution par le résultat d'une opération quelconque.

On dispose de la taille :

size_t taille;
<...>
taille = 80;

On alloue la place nécessaire pour la chaîne (j'utilise calloc de préférence à malloc, car calloc initialise la zone mémoire réservée à zero, ce qui permet de positionner directement le caractère terminal de chaîne) :

char *tab = calloc(taille, sizeof *tab);
/* ne pas oublier de tester le retour de la fonction calloc... */

On construit maintenant dynamiquement la chaîne de formatage :

int t_format = 81;
char *format = calloc(t_format, sizeof *format);
/* tester le retour de calloc... */

snprintf(format, t_format, "%%%ds", taille);

On a donc la chaîne format qui ressemble à "%80s". (Les deux premiers '%' servent a imprimer le caractere '%' dans la chaîne ; puis vient '%d' permettant d'imprimer la taille dans la chaîne ; puis enfin 's' sera imprimé, indiquant le format qui sera utilisé par scanf...)

Remarque :
snprintf n'est pas ANSI, mais est conforme à la norme C99. Il est préférable de l'utiliser car elle permet de contrôler la taille de ce qui sera imprimé, de manière à éviter un débordement de buffer... Attention tout de même, elle n'imprime que taille-1 caractères, de manière à mettre automatiquement le '\0' final.

Récuperation d'une ligne entière

On peut réaliser le pendant de la fonction fgets avec scanf, mais cela nécessite l'utilisation des expressions rationnelles.

Je vais brièvement vous introduire celles-ci, de manière à ce que vous en compreniez les tenants et les aboutissants nécessaires à la compréhension de certaines astuces...

Tout d'abord, les expressions rationnelles (notées ER dans la suite de ce document), sont exprimées entre les caractères '[' et ']' .
Entre ces deux bornes, on peut définir un ensemble non vide de caractères quelconques, qui serviront de "filtre", appliqué sur le flux entrant, de manière à faire une sélection plus "fine" qu'avec les formateurs standards.

Rappel :
Pour le formateur '[' (i.e. les ER), l'espace alloué pour recevoir la chaîne doit etre suffisamment grand pour contenir la chaîne ET un caractere terminal '\0' (ajouté automatiquement par scanf).
Par ailleurs, les espaces précédant la saisie "effective" ne sont pas supprimés par défaut, revoir le paragraphe concernant ce comportement (avec '%c') pour plus de détails.

Ainsi, on a vu que %s prenait les caractères différents d'un caractere d'espacement. On pourrait avoir envie d'affiner, et ne valider que les séquences contenant uniquement des lettres.
On aurait donc :

char tab[81] = {0};
scanf(" %80[abcdefghijklmnopqrstuvwxyz]", tab);

Si vous avez bien tout suivi jusqu'à maintenant, vous aurez remarqué le formatage que j'ai utilisé :

un espace avant le '\%' pour supprimer les espaces précédant la saisie.
un champ taille de 80 caractères, de manière à effectuer un contrôle sur la taille de la saisie en cours.
entre crochets, les lettres représentant les critères de validation d'une saisie : ainsi, toute saisie contenant autre chose que ce qui est présent dans l'ensemble de caractère sera considérée comme fausse, et scanf terminera en erreur.

On remarque que l'ensemble précédent (les lettres) est particulièrement fastidieux à taper. Il existe la possibilité d'utiliser le caractère '-' (tiret) pour les ensembles "courants". On peut de plus préciser plusieurs de ces ensembles entre les crochets de l'ER :

scanf(" %80[a-zA-Z]", tab);

L'ensemble décrit ci-dessus concerne les lettres de l'alphabet, qu'elles soient en majuscule ou en minuscule. Ainsi, une saisie comme suit sera valide :

      'HellOWorLd'

Toute saisie contenant d'autres caractères sera rejetée.

Maintenant, venons-en à la recuperation d'une ligne complète. (bha oui, c'était le but du chapitre ;-)

Une ligne est définie par une suite de caractères et terminée par un retour chariot '\n'.
Si on veut récupérer une ligne entière, il suffit donc de prendre tous les caractères différents de '\n'.
On ne va pas entrer tous les caractères imaginables satisfaisant cette condition. Il existe un autre caractere spécial, le '^' (circonflexe) permettant de dire : toute saisie ne contenant pas les caractères suivants est considérée comme valide.

Etudions le code suivant :

char tab[81] = {0};
char c;

if (scanf("%80[^\n]", tab) == 1) {
    /* saisie reussie */
    if ( (c = getchar()) != '\n') {
        /* saisie trop longue */

    }

}
else{
    /* echec de la saisie */

}

On remarque :

un champ taille (80)
un ensemble précisant : tout sauf '\n'
le test du retour de scanf :

[*] s'il vaut 1, alors la saisie a réussie : cela veut dire que tab a été correctement rempli. Par contre, on ne sait pas s'il reste des caractères disponibles, à cause du champ taille. En effet, comme on a précisé celle-ci, il se peut que la fonction scanf se soit arrêtée alors que le flux entrant contient encore des caractere différents de '\n'.

On fait un 'getchar' pour récupérer le prochain caractère (il y en a forcement au moins un si scanf a réussi)
-- s'il est égal à '\n', alors c'était le dernier du flux.
-- s'il en est différent, c'est qu'il reste des caractères en trop. On peut choisir de vider le flux de manière standard, en utilisant getchar, ou bien faire un autre traitement, tout dépend de ce que l'on souhaite faire.

[*] s'il est différent de 1, c'est que la saisie a échoué. Dans l'exemple pris plus haut, ce cas peut arriver uniquement en cas de saisie d'une ligne vide (ne contenant qu'un '\n').
Il faut donc vider le flux entrant. En effet, la fonction scanf s'est arrêtée avant le '\n' : ce caractère reste donc présent sur le flux ! (un simple getchar fait l'affaire :-)

Rappel :
La manière standard de vider le flux entrant est la suivante :

Sélectionnez

int c;
while ( ((c = getchar()) != '\n') && c != EOF)
{
};

Utilisation de scanf dans une boucle

On l'a vu dans le paragraphe précédent, l'échec (ou non !) de scanf lors d'une saisie est susceptible de laisser des caractères sur le flux entrant. Ce comportement est particulièrement gênant en cas d'utilisation de scanf à l'intérieur d'un boucle.

Il existe une expression rationnelle que l'on peut rajouter à la fin de celle(s) qui nous intéresse(nt). On va utiliser le spécificateur '*' pour indiquer que l'on ignore le résultat.

Ainsi, vider le flux reviendra à dire : "je valide tout caractère différent de '\n' (avec '^'), et je me fiche du résultat (avec '*')". Cela donne :

scanf("%*[^\n]");

On ne précise pas de pointeur, puisque l'on "jette" le résultat. Par contre, il reste encore et toujours le même caractere dans le flux entrant : ce satané '\n'. On lui règle son compte avec un 'getchar' bien senti.
Vider le flux entrant avec scanf revient au final à :

scanf("%*[^\n]");
getchar();

Lors de l'utilisation dans une boucle, si, par exemple, on souhaite que l'utilisateur entre un nombre, et que l'on veuille le lui redemander tant que ce n'est pas le cas, le code serait le suivant :

#include <stdio.h>

int main(void){

  int nombre = 0;
  int ok = 0;
  int retour;

  printf("saisissez un nombre :\n");
  while (!ok){

    retour = scanf("%d%*[^\n]", &nombre);
    printf("retour : %d\n", retour);
    if ( !retour ){
      /* erreur de saisie, on vide le flux */
      int c;
      while ( ((c = getchar()) != '\n') && c != EOF);

      printf("on vous a demande de saisir un nombre\n");
      printf("veuillez recommencer :\n");
    }
    else {
      /* reussite de la saisie */
      getchar(); /* on enleve le '\n' restant */

      printf("saisie acceptee\n");
      ok = 1;  /* sort de la boucle */
    }

  }

  return 0;
}

En fait, si la saisie échoue (valeur de retour de scanf égale à zero dans le cas présent), il faut vider explicitement stdin (avec getchar par exemple, mais on pourrait utiliser scanf comme vu plus haut ;-).
Par contre, en cas de reussite, l'ER suivante : '%*[^\n]' permet de supprimer tout caractere restant. C'est le cas si, par exemple, on a une saisie du genre: '1234azertyuiop poiuytreza' Le nombre 1234 sera récupéré, et le reste du flux purgé.

Par contre, en cas de reussite, il ne faut pas oublier d'enlever le '\n' restant...

Vérification du retour de scanf, et outils de contrôle

Scanf retourne le nombre d'éléments assignés correctement. Ainsi, un retour de valeur zero signifie qu'aucun élément n'a été assigné ; c'est le cas lorsque l'on entre une lettre alors que le formateur est '%d'.

Il faut noter le cas particulier que constitue l'utilisation de l'étoile '*', signifiant que l'on ignore le résultat. En effet, même si la conversion a réussie, comme rien n'a été affecté, elle ne sera pas comptabilisée.

Dans le cas suivant, si tout se passe bien, la valeur de retour de scanf sera 1 (et non 2) :

int a;
scanf("%d%*[^\n]", &a);

Imaginons maintenant que nous voulions récupérer certains éléments d'une saisie, et en passer d'autres.

Exemple :
" 123azerty456uiop789 "
Et on ne veut récupérer que les nombres aux extrémités, tout en vérifiant que la ligne suit le pattern suivant :

[nombre]~[lettres]~[nombre]~[lettres]~[nombre]

On peut imaginer de faire comme suit :

Sélectionnez

int a, b;
scanf("%d%*[a-z]%*[0-9]%*[a-z]%d", &a, &b);
  • On récupère le premier entier
  • On vérifie sans assignement qu'il existe bien une suite de lettres (minuscules)
  • On vérifie sans assignement qu'il existe bien une suite de chiffres
  • On vérifie sans assignement qu'il existe bien une suite de lettres (minuscules)
  • On récupère le second entier

On a vu que si scanf réussi, on aura comme valeur de retour 2 (puisqu'on assigne deux éléments).
Mais en cas d'échec, la valeur ne pourra être que 0 (rien d'assigné) ou 1 (premier entier).

Si l'on veut faire une gestion fine des erreurs, voire reprendre sur ces mêmes erreurs, on ne dispose pas assez d'informations pour savoir ou exactement scanf a echouée :
Est-ce à cause

  • Des symboles ou des majuscules dans la première séquence de lettres ?
  • pas de seconde séquence de chiffre ?
  • Des symboles ou des majuscules dans la seconde séquence de lettres ?

Nous allons donc aborder le dernier formateur utile de scanf : le formateur '%n'.
Ce formateur nous permet de connaître le nombre de caractères lus par scanf au moment de l'assignement. Le pointeur associé est un pointeur sur int.

Note :
Le nombre de caractères lus est différent du nombre d'assignations réalisées.

Reprenons donc l'exemple ci-avant. Si l'on veut connaître où peut avoir echoué scanf, il suffit de déclarer quelques variables supplémentaires.
Ainsi :

int a = 0;
int  b = 0;
int seq_1_lettres = 0;
int seq_chiffre = 0;

scanf("%d%*[a-z]%n%*[0-9]%n%*[a-z]%d", &a, &seq_1_lettres, &seq_chiffre, &b);

A propos du formateur %n. La norme précise que la valeur assignée n'est pas comptabilisée dans le retour de scanf : c'est comme si l'on avait précisé le drapeau '*'. Cependant, il est à noter que certaines implantations ne respectent pas ce principe, et il est donc fortement conseillé de ne plus tenir pour absolument exact le retour de scanf lorsque l'on utilise ce formateur.

On peut donc tester le retour de scanf de la façon suivante :

if(!b) {
  /* b n'a pas ete assigne, on en deduit un echec de scanf */
  if(!a){
    /* echec complet de scanf */
          ...
  } else {
    /* au moins a est correct */
    if (!seq_1_lettres){
      /* c'est la sequence de lettre qui n'est pas passee */
         ....
    } else if (!seq_chiffre) {
      /* la sequence de chiffre a plante... */
      ....
    }
  }

} else {
  /* b a ete correctement rempli, c'est donc que scanf s'est correctement deroule jusqu'au bout*/
    ....
}

.....

Conclusion

Pour conclure, si l'on souhaite utiliser la fonction scanf, il est important de connaître plusieurs choses.
Tout d'abord, il faut bien comprendre les mécanismes que mettent en jeu les formateurs "simples" de la fonction( d, i, o, u, x, e, f, g).
Autre chose, il faut toujours avoir à l'esprit les subtilités inhérentes à la présence des espaces dans cette même chaîne de formatage...
Par ailleurs, si l'on veux faire de la saisie correcte et sécurisée, il est très important de contrôler la taille de la saisie utilisateur via les champs associés, ce afin d'éviter tout débordement de buffer.
De même, il faut se rappeler que certains formateurs (s, et '[' ) ajoutent automatiquement un '\0' à la saisie utilisateur, ce qu'il faut prendre en compte lorsqu'on alloue l'espace de stockage.
Enfin, ne pas oublier que lors de l'utilisation répétée de fonctions de saisie (que ce soit de manière séquentielle ou via une boucle), il est important de contrôler le retour de ces fonctions, de manière à savoir s'il faut vider le buffer d'entrée (pour éviter tout comportement indéfini).

Une fois ces points bien compris, vous êtes prêts ! :-)


melem.developpez.com :: La compilation séparée en C

La compilation séparée en C

Table des matières

  • I. Généralités
  • II. Les bibliothèques
  • III. Conclusion

La compilation séparée désigne le fait de compiler plusieurs fichiers sources séparément puis de les lier ensuite pour générer le produit final qui peut être un exécutable par exemple. Elle comprend plusieurs techniques que nous allons explorer tout au long de ce tutoriel.

I. Généralités

I-A. Compilation d'un projet

I-A-1. Introduction

Un projet d'application en langage C est au moins constitué d'un fichier source qui sera compilé, ce qui donnera un fichier objet en sortie, puis lié à d'autres fichiers objets pour générer la sortie finale qui peut être un exécutable par exemple. Dans le cas général, un projet est constitué de plusieurs fichiers sources.

I-A-2. Exemple avec un projet constitué de deux fichiers sources

I-A-2-a. Le projet

Nous allons créer un programme qui affiche la somme et le produit de deux nombres entiers en séparant le programme et les fonctions (somme et produit) dans deux fichiers différents.

Fichier : exemple.c
#include <stdio.h>

int somme(int a, int b);
int produit(int a, int b);

int main()
{
    int a = 2, b = 5;

    printf("%d + %d = %d\n", a, b, somme(a, b));
    printf("%d * %d = %d\n", a, b, produit(a, b));

    return 0;
}
Fichier : somme.c
int somme(int a, int b)
{
    return a + b;
}

int produit(int a, int b)
{
    int prod = 0;

    while (b-- > 0)
        prod += a;

    return prod;
}

I-A-2-b. Compilation sous Code::Blocks

Pour compiler le projet, sélectionnez la commande Build > Build (Ctrl + F9). Cette compilation se fait en deux phases : compilation des fichiers sources (exemple.c et fonctions.c) puis édition des liens. On peut aussi compiler les fichiers sources individuellement : Build > Compile Current File (Ctrl + Shift + F9). Dans ce cas la commande Build (Ctrl + F9) ne fera plus que l'édition des liens.

I-B. Le mot-clé extern

En langage C, tout objet (variable ou fonction) doit toujours avoir été déclaré avant d'être utilisé. Nous avons déjà résolu le problème pour les fonctions, ne reste donc plus que les variables. Supposons donc que l'on souhaite, depuis un fichier donné, accéder à une variable globale définie dans un autre fichier. On ne peut pas tout simplement déclarer une deuxième fois la variable car on aurait alors deux variables de même nom au sein d'un même projet ce qui conduirait à une erreur lors de l'édition des liens. Le mot-clé extern permet de résoudre le problème. Placé devant une déclaration, il permet d'indiquer que la variable ou fonction est définie (plus précisément : peut être définie) dans un autre fichier (source ou objet). Placé devant une définition, il permet d'indiquer que la variable ou la fonction est visible dans tout le projet, ce qui est déjà le comportement par défaut.

I-C. Le mot-clé static

Le mot-clé static permet de restreindre la visibilité de la variable ou de la fonction au fichier source courant. On a alors ce qu'on appelle une variable ou fonction privée. Par exemple :

#include <stdio.h>

static int id(int x); /* Declaration de la fonction "privee" id() (utilisee dans main()). */

int main()
{
    int x = 0;

    printf("id(%d) = %d\n", x, id(x));

    return 0;
}

int id(int x)
{
    return x;
}

I-D. Les fichiers d'en-tête

Les fichiers d'en-tête (*.h) permettent de rassembler des « en-têtes » (c'est-à-dire des déclarations de fonctions, des définitions de macros, de types, etc.) communes à plusieurs fichiers sources et/ou fichiers d'en-tête. Mais puisqu'ils peuvent justement être inclus par un grand nombre de fichiers, le risque d'être inclus plus d'une fois dans un même fichier est très élevé. C'est pour cette raison que les fichiers d'en-têtes doivent impérativement être protégés contre les inclusions multiples. La technique fait appel au préprocesseur : une macro indique si le fichier est déjà inclus. Il suffit donc de tester dès le début du fichier si la macro est définie ou non. Le schéma est donc le suivant :

#ifndef DRAPEAU

#define DRAPEAU

/* --------- */
/*           */
/* --------- */

#endif

Dans l'exemple de projet précédent, on aurait pu par exemple créé un fichier somme.h contenant la déclaration des fonctions somme et produit du fichier somme.c.

Fichier : somme.h
#ifndef H_SOMME_H

#define H_SOMME_H

int somme(int a, int b);
int produit(int a, int b);

#endif

Le fait d'avoir choisi H_SOMME_H plutôt que SOMME_H ou __SOMME_H__ comme drapeau n'est pas du tout le fruit du hasard. Il permet de ne pas entrer en conflit avec les identifiants reservés du langage. Par exemple, les identifiants commencants par E, LC_, SIG, etc. sont reservés (E pour les numéros d'erreur de errno.h, LC_ pour les constantes définies par locale.h et SIG pour le les signaux de signal.h). H_ en début d'un identifiant est pour l'heure encore libre, alors en profiter.

Il suffit maintenant d'inclure ce fichier partout où on a besoin des fonctions somme et produit. Par exemple :

Fichier : exemple.c
#include <stdio.h>
#include "somme.h"

int main()
{
    int a = 2, b = 5;

    printf("%d + %d = %d\n", a, b, somme(a, b));
    printf("%d * %d = %d\n", a, b, produit(a, b));

    return 0;
}

Lorsque le nom d'un fichier est mis entre guillemets comme dans cet exemple, le préprocesseur va d'abord chercher le fichier dans le même répertoire que celui du fichier source puis s'il ne le trouve pas, va le chercher dans le ou les répertoires par défaut (spécifiques du compilateur). On peut également spécifier un chemin absolu.

I-E. Les structures opaques

Une structure est dite opaque lorsque son implémentation (c'est-à-dire sa définition) est cachée. L'accès aux champs de la structure se fait alors par l'intermédiaire de fonctions dont l'implémentation évidemment est également cachée. En particulier, l'interface devra au moins fournir une fonction permettant de créer l'objet (constructeur) et une fonction permettant de le détruire (destructeur). Par exemple, nous allons encapsuler le type int dans une structure de type integer_s.

Implémentons la structure dans un fichier integer.c

Fichier : integer.c
#include <stdlib.h>

struct integer_s {
    int value;
};

struct integer_s * integer_create_object()
{
    return malloc(sizeof(struct integer_s));
}

void integer_set_value(struct integer_s * p_object, int value)
{
    p_object->value = value;
}

int integer_get_value(struct integer_s * p_object)
{
    return p_object->value;
}

void integer_delete_object(struct integer_s * p_object)
{
    free(p_object);
}

Fournissons maintenant l'interface via un fichier d'en-tête : integer.h

integer.h
#ifndef H_INTEGER

#define H_INTEGER

struct integer_s;

struct integer_s * integer_create_object(void);
void integer_set_value(struct integer_s * p_object, int value);
int integer_get_value(struct integer_s * p_object);
void integer_delete_object(struct integer_s * p_object);

#endif

La ligne :

struct integer_s;

déclare la structure (sans la définir). C'est ce qu'on appelle une déclaration incomplète. Comme la définition de la structure n'apparaît pas plus bas, La structure est alors opaque.

Voici un exemple d'utilisation de integer.h :

exemple.c
#include <stdio.h>
#include "integer.h"

int main()
{
    struct integer_s * i = integer_create_object();

    if (i != NULL)
    {
        integer_set_value(i, 100);
        printf("The value of i is : %d\n", integer_get_value(i));
        integer_delete_object(i);
    }

    return 0;
}

II. Les bibliothèques

II-A. Introduction

Une bibliothèque (library en anglais) est en première approximation un bouquet de fonctions. Chez certains langages elles sont appelées unités ou paquetages mais le principe reste le même. En langage C, il faut en fait également fournir le ou les fichiers d'en-tête correspondants (contenant la déclaration des fonctions de la bibliothèque, des macros et/ou types supplémentaires, etc.) avant de réellement en constituer une.

La manière de créer et d'utiliser une bibliothèque est très dépendante de l'environnement avec lequel on travaille. Dans ce tutoriel nous allons expliquer essentiellement la procédure pour MS Visual Studio .NET donc évidemment sous Windows.

Comme nous le savons déjà, la compilation d'un fichier source ne produit pas un exécutable mais un module objet (.o ou .obj), que l'on peut considérer comme la version machine du fichier source original. Il faut ensuite lier différents modules objets pour produire un exécutable.

Un module objet est réutilisable (on peut donc voir un tel fichier comme une véritable « boîte à outils »). C'est là d'ailleurs toute l'importance de la compilation séparée. Reprenons par exemple le fichier somme.c contenant le code des fonctions somme et produit. En compilant ce fichier, on obtient un fichier somme.obj.

Maintenant, créons un nouveau projet dans lequel nous allons utiliser les fonctions somme et produit. Voici le programme :

Fichier : exemple.c
#include <stdio.h>

int somme(int a, int b);
int produit(int a, int b);

int main()
{
    int a = 2, b = 5;

    printf("%d + %d = %d\n", a, b, somme(a, b));
    printf("%d * %d = %d\n", a, b, produit(a, b));

    return 0;
}

Si on compile ce fichier, il n'y a aucune erreur puisque tout est syntaxiquement correct, l'équivalent en langage machine peut donc être généré. Par contre si on tente de générer l'exécutable, on aura un message d'erreur indiquant que l'édition des liens a échoué car les fonctions somme et produit n'ont pu être trouvés. Il faut donc dire au linkeur qu'il doit également chercher dans somme.obj lors de l'édition des liens. La procédure est évidemment dépendante de l'environnement de développement. Sous Code::Blocks, c'est dans Project > Build Options > Linker > Link Libraries. Sous Visual Studio .NET c'est Project > Properties > Configuration Properties > Linker > Input > Additional Dependencies. Il suffit ensuite d'ajouter somme.obj. Ce n'est pas plus différent non plus avec les autres EDIs.

Evidemment si vous ne spécifiez pas de chemin complet, le linkeur va supposer que le fichier se trouve dans le répertoire par défaut pour les libs (généralement un dossier nommé LIB dans le répertoire d'installation du compilateur) qui est bien entendu spécifique du linkeur.

II-B. Les bibliothèques statiques

Une bibliothèque statique (.lib ou .a) est un fichier qui regroupe un ou plusieurs modules objets. Elles s'utilisent donc de la même manière que ces derniers. Pour créer une bibliothèque statique avec Visual Studio .NET, créez un nouveau projet Win32 (Win32 Project) puis dans Paramètres de l'application (Application settings), choisissez Bibliothèque statique (Static library). Cochez l'option Projet vide (Empty project) afin qu'aucun fichier source ne soit automatiquement ajouté au projet. A la fin, compilez le projet à l'aide du menu Générer (Build).

II-C. Les bibliothèques dynamiques

Une bibliothèque dynamique est un fichier qui ne sera effectivement lié à l'exécutable que pendant l'exécution. Cela présente plusieurs avantages. Supposez par exemple que vous avez créé une bibliothèque statique et que vous l'avez ensuite utilisé dans de nombreuses applications. Si un jour vous la modifiez et que vous voulez également mettre à jour toutes vos applications, vous devrez les recompiler une par une ! Pourtant si vous avez utilisé une bibliothèque dynamique, la modification seule de ce fichier aura des répercussions sur toutes les applications l'utilisant puisque la liaison avec le fichier ne se fait que pendant l'exécution. De plus, si vous avez bien compris, l'utilisation des bibliothèques dynamiques rend les exécutables plus petits (en terme de taille) puisque ce dernier même est incomplet. En effet, il a besoin du code contenu dans la bibliothèque pour fonctionner.

Sous Windows, les bibliothèques dynamiques sont appelées DLLs (.dll) pour Dynamic-Link Library. Sous UNIX on les appelle Shared Objects (.so). Dans ce tutoriel, nous-nous intéresserons aux DLLs.

Nous allons donc créer une DLL (dsomme.dll) exportant deux fonctions : somme et produit. Que signifie exporter ? Ben c'est très simple : lorsqu'on développe une bibliothèque, on peut spécifier quelles fonctions (ou variables) seront « publiques » (ou exportées), c'est-à-dire accessibles depuis l'extérieur, et lesquelles seront « privées », c'est-à-dire réservées à usage interne. Ce qui ne sont pas exportées sont privées.

La question est donc maintenant : comment exporter une fonction. Et ben il y a plusieurs manières de le faire, par exemple à l'aide du modificateur __declspec(dllexport). Bien entendu, il s'agit bien d'une extension Microsoft aux langages C et C++ (en ce qui nous concerne : le langage C) et qui fut ensuite repris par la plupart des implémentations pour Windows. Donc pas de problème que vous compilez avec MingW ou Borland C++ ...

Sous Visual Studio .NET, créez un nouveau projet Win32 (Win32 Project) puis dans Paramètres de l'application choisissez DLL. Cochez l'option Projet vide afin qu'aucun fichier ne soit automatiquement ajouté au projet. Ajoutez ensuite un fichier dsomme.c puis saisissez le code suivant :

Fichier : dsomme.c
__declspec(dllexport) int somme(int a, int b)
{
    return a + b;
}

__declspec(dllexport) int produit(int a, int b)
{
    int prod = 0;

    while (b-- > 0)
        prod += a;

    return prod;
}

Compilez ensuite le projet avec la commande Build du menu Build. Vous obtiendrez entre autres en sortie deux fichiers : dsomme.dll et dsomme.lib. Ce dernier, bien que portant l'extension .lib, n'est pas une bibliothèque statique mais une bibliothèque d'importation. C'est lui qu'il faut passer au linkeur lors de l'édition des liens pour pouvoir compiler du code dépendant d'une DLL. A l'exécution, le programme doit pouvoir localiser la DLL. Cette dernière doit donc se trouver soit dans le même répertoire que le programme, soit dans le répertoire courant du programme, ou encore dans un répertoire « connu » du système par exemple le répertoire system32.

La déclaration de fonctions à importer depuis une DLL (via la bibliothèque d'importation) peut se faire comme la déclaration d'une fonction « normale », cependant le modificateur __declspec(dllimport) permet d'indiquer au compilateur (je dis bien le compilateur, pas le linkeur) que la fonction en question se trouve dans une bibliothèque dynamique, ce qui lui permettra de générer du code plus efficace (plus « direct »). Sans cela, le compilateur va tout simplement convertir chaque appel de la fonction en appel de celle qui se trouve dans le .lib, qui ne fait rien de plus qu'un appel à la fonction dans la DLL ce qui fait donc finalement deux appels, ce qui est évidemment plus long qu'un appel direct. Notre programme sera donc :

Fichier : exemple.c
#include <stdio.h>

__declspec(dllimport) int somme(int a, int b);
__declspec(dllimport) int produit(int a, int b);

int main()
{
    int a = 2, b = 5;

    printf("%d + %d = %d\n", a, b, somme(a, b));
    printf("%d * %d = %d\n", a, b, produit(a, b));

    return 0;
}

Et n'oubliez pas : nous devons nous lier avec dsomme.lib.

II-D. Applications. Exemples

II-D-1. La bibliothèque standard du langage C

Sous Windows, la bibliothèque « standard » du langage C, c'est-à-dire celle qui contient entre autres le code des fonctions standard du C est implémentée en tant que bibliothèque dynamique connue sous le nom de Microsoft C Run-Time Library (CRT), et qui correspond au fichier msvcrt.dll (ce fichier se trouve dans le répertoire système). Les compilateurs utilisent généralement cette bibliothèque comme corps de la bibliothèque standard mais rien n'empêche un compilateur particulier de disposer de sa propre bibliothèque d'exécution. Visual C++ 2005 (Visual C++ 8.0) par exemple utilise msvcr80.dll (msvcr80d.dll en mode Debug) au lieu de msvcrt.dll.

II-D-2. Le concept d'API

Une API ou Application Programming Interface (Interface de Programmation d'Applications) est un ensemble de fonctions exposées par un système ou un logiciel pour permettre à d'autres logiciels d'interagir (c'est-à-dire de communiquer) avec lui. Par extension, toute fonction d'une API donnée est également appelée : une API. Sous UNIX, les APIs du système sont appelés appels système.

Les DLLs sont très utilisées sous Windows et le système lui-même expose son API via de nombreuses DLLs. Les programmes conçues spécifiquement pour Windows se lient donc à un ou plusieurs DLLs de l'API Windows.

III. Conclusion

La compilation séparée permet de mieux organiser ses projets et de développer des bibliothèques de fonctions. C'est donc une technique qu'il faut absolument maîtriser si on veut développer sérieusement en C.


Note sur le C

Notes sur le langage C

Ce document est un peu disparate. En effet, il contient une multitude d'articles plus ou moins longs qui sont souvent des réponses que j'ai pu apporter à des questions souvent posées sur les forums.

Je me suis efforcé ensuite de les organiser en chapitres de la façon suivante :

  • Généralités ;
  • Éléments du langage ;
  • Fonctions ;
  • Types et Variables ;
  • Pointeurs, tableaux, chaînes ;
  • Entrées / sorties ;
  • Bibliothèque standard ;
  • Codage ;
  • Divers.

Liste des articles d'Emmanuel Delahaye Vu dans developpez.com

  • Initiation Pragmatique au langage C http://emmanuel-delahaye.developpez.com/tutoriels/c/initiation-pragmatique-c
  • Bonnes pratiques de codage en C http://emmanuel-delahaye.developpez.com/tutoriels/c/bonnes-pratiques-codage-c/
  • Bibliothèque CLIB http://emmanuel-delahaye.developpez.com/tutoriels/c/bibliotheque-clib-c/
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C http://emmanuel-delahaye.developpez.com/tutoriels/c/concevoir-realiser-composant-logiciel-c/
  • Les types abstraits de données (ADT) http://emmanuel-delahaye.developpez.com/tutoriels/c/types-abstraits-donnees-c/
  • Notes sur les réseaux http://emmanuel-delahaye.developpez.com/tutoriels/c/notes-reseaux-c/
  • L'outil MAKE http://emmanuel-delahaye.developpez.com/tutoriels/c/make-c/
  • POSIX threads http://emmanuel-delahaye.developpez.com/tutoriels/c/posix-threads-c/
  • Les listes chaînées en langage C http://emmanuel-delahaye.developpez.com/tutoriels/c/listes-chainees-c/
  • Entrées solides en langage C http://emmanuel-delahaye.developpez.com/tutoriels/c/entrees-solides-c/
  • Introduction au développement d'un projet informatique http://emmanuel-delahaye.developpez.com/tutoriels/c/developpement-projet-informatique-c/
  • Opérateurs bit à bit http://emmanuel-delahaye.developpez.com/tutoriels/c/operateurs-bit-bit-c/


Note sur le C

Initiation pragmatique au langage C

  • I. Introduction http://emmanuel-delahaye.developpez.com/tutoriels/c/initiation-pragmatique-c
  • II. Programme minimum
    • II-A. Analyse détaillée
  • III. Un programme qui dit "bonjour"
  • IV. Glossaire
    • IV-A. Bit
    • IV-B. Byte
    • IV-C. Caractère
    • IV-D. Chaîne de caractères
    • IV-E. Comportement indéfini
    • IV-F. Fonction
    • IV-G. Flux
    • IV-H. Paramètre
    • IV-I. Préprocesseur
    • IV-J. printf()
      • IV-J-1. Les principaux formateurs de printf()
      • IV-J-2. Exemples d'utilisation de printf()
    • IV-K. Prototype
    • IV-L. sizeof
    • IV-M. Tableau
    • IV-N. Type

Présentation des bases du langage C par une approche progressive, mais concrète et basée sur l'expérimentation.

I. Introduction

Il existe de nombreuses façons d'expliquer les bases d'un langage de programmation. J'ai choisi une approche progressive, mais concrète et basée sur l'expérimentation. Les informations données ne prétendent pas être exhaustives, et font référence à la définition du langage C selon la norme ISO/IEC 9899:1990 (appelée aussi C90).

Pour les détails, il est conseillé de se munir d'un livre de référence comme The C programming language (et son correctif) de Brian Kernighan et Dennis Ritchie ou sa version française : Le langage C. Les solutions aux exercices sont dévoilées ici.

Le langage C est un langage normalisé qui comprend des instructions de préprocesseur, des instructions C, et des symboles dont la sémantique peut varier selon le contexte.

II. Programme minimum

Voici le programme C le plus simple que l'on puisse écrire.

Sélectionnez
int main (void)
{
   return 0;
}

Ce programme est composé de plusieurs mots clés faisant partie du langage C : int, void et return

Il utilise aussi 5 symboles syntaxiques, à savoir (, ), {, } et ;. Enfin, il utilise un mot défini par l'utilisateur : main.

Ce programme ne fait rien de visible. Cependant, il met en œuvre la structure de code de base du langage C, à savoir la fonction.

II-A. Analyse détaillée

Nous allons étudier les mots et le symboles qui constituent le programme dans l'ordre de leur apparition.

int est un type. Ici, il qualifie la valeur retournée par la fonction.

main est un identificateur défini par l'utilisateur. Utilisé dans ce contexte, il désigne le nom de la fonction. Cet identificateur a cependant un sens particulier. Il désigne le point d'entrée du programme. Cela signifie qu'un programme C commencera toujours par un appel 'invisible' à la fonction main(). Cet appel provient de la séquence d'initialisation du programme. Les détails dépendent de l'implémentation, mais, d'une manière simplifiée, voici quel est le déroulement des opérations :

  1. chargement du logiciel ;
  2. exécution du code d'initialisation ("boot") ;
  3. appel de l'application (CALL MAIN) ;
  4. exécution de l'application, jusqu à la fin de main() ;
  5. exécution du code de fin ;
  6. retour au système.

void est un mot clé qui signifie 'rien' ou 'absence de'. Ici, il sert à indiquer que la fonction n'a pas de paramètres.

return est un mot clé qui signifie "quitter la fonction courante". Si la fonction n'a pas de type de retour (type void) on peut utiliser return tout seul suivit d'un point-virgule. Sinon, il doit être suivi d'une valeur et d'un point-virgule. La valeur doit être du type qui a été défini pour la fonction. Cette valeur peut avoir un sens particulier. Il est d'usage de retourner 0 pour signifier que tout va bien (pas d'erreur) et une valeur autre que 0 pour signifier une erreur.

L'usage d'un 'return' sans valeur de retour dans une fonction qui attend une valeur de retour provoque un comportement indéfini.

III. Un programme qui dit "bonjour"

Voici une version un peu plus élaborée, qui se contente d'afficher une simple phrase ("Hello world!") sur la sortie standard (généralement l'écran).

Sélectionnez
#include <stdio.h>

int main (void)
{
   puts ("Hello world!");
   return 0;
}

Dans ce programme, on constate l'ajout du mot clé #include et de son paramètre <stdio.h>, ainsi que la fonction puts(). et de son paramètre "Hello world!".

On remarque que le mot clé #include commence par un '#'. En effet, ce mot clé appartient à la famille des commandes du préprocesseur, dont une caractéristique est justement de commencer par '#'.

#include <stdio.h> signifie que le fichier indiqué en paramètre est inclus dans le code source. Cette opération (ainsi que toutes les opérations du préprocesseur) est effectuée avant la traduction du code.

Cette opération d'inclusion est rendue nécessaire à cause de l'appel à la fonction puts(). En effet, il est recommandé (et parfois obligatoire) de fournir au compilateur un prototype à la fonction utilisée :

Sélectionnez
int puts (char const *);

La fonction puts() appartient à la bibliothèque standard du langage C, qui fait partie intégrante du langage. La définition du prototype de cette fonction se trouvant dans le fichier d'en-tête <stdio.h>, il est donc tout à fait logique d'inclure ce fichier dans le fichier source.

Cette fonction a pour effet d'émettre la chaîne de caractères passée en paramètre vers le flux de sortie standard. Elle ajoute automatiquement un caractère de fin de ligne.

A première vue, le paramètre de cette fonction est une chaîne de caractères, En fait, c'est l'adresse de celle-ci. C'est pourquoi le paramètre de fputs() est défini comme un pointeur sur un type char. En effet, un pointeur est une variable qui peut contenir une adresse.

Le qualificateur const signifie que la fonction accepte l'adresse d'une chaîne non modifiable, car elle s'engage à ne pas modifier la chaîne pointée, ni à passer son adresse à une fonction qui pourrait la modifier.

IV. Glossaire

IV-A. Bit

Le bit (BInary digiT) ou chiffre binaire est l'unité de mesure d'information. Il peut prendre 2 valeurs : 0 ou 1.

IV-B. Byte

Le byte (ou multiplet) est le plus petit objet adressable pour une implémentation donnée. En langage C, il fait au moins huit bits.

IV-C. Caractère

Un caractère est une valeur numérique qui représente un glyphe visible ('A', '5', '*' etc.) ou non (CR, LF etc.). Un caractère est de type int. Cependant, sa valeur tient obligatoirement dans un type char.

Un objet de la taille d'un byte peut recevoir la valeur de n'importe quel caractère.

IV-D. Chaîne de caractères

Une chaîne de caractères est une séquence de caractères encadrée de double quotes.

Sélectionnez
"Hello world!"

La représentation interne d'une chaîne de caractères est spécifiée. C'est un tableau de char terminé par un 0.

Sélectionnez
char s[] = "Hello";

est équivalent à :

Sélectionnez
char s[] = {'H', 'e', 'l', 'l', 'o', 0};

IV-E. Comportement indéfini

Un comportement indéfini (encore appelé Undefined Behaviour ou UB) est un bug grave. Il signifie que le comportement du programme est imprévisible et que tout peut arriver, y compris, et c'est ça qui rend ce bug très dangereux, un comportement d'apparence conforme.

Il n'existe pas de moyen automatique de détecter tous les UB d'un programme. Seul un contrôle visuel fait par une personne chevronnée permet de détecter un tel bug.

IV-F. Fonction

Une fonction est une séquence d'instructions dans un bloc nommé. Une fonction est constituée de la séquence suivante :

  • un type (ou le mot clé void) ;
  • un identificateur (le nom de la fonction, ici main) ;
  • une parenthèse ouvrante ( ;
  • une liste de paramètres ou une liste vide (dans ce cas, on écrit void) ;
  • une parenthèse fermante ) ;
  • une accolade ouvrante { ;
  • une liste d'instructions terminée par un point virgule ;, ou rien ;
  • une accolade fermante }.

L'utilisateur peut créer ses propres fonctions. Une fonction peut appeler une autre fonction. Généralement, une fonction réalisera une opération bien précise. L'organisation hiérarchique des fonctions permet un raffinement en partant du niveau le plus élevé (main()) en en allant au niveau le plus élémentaire (atomique).

On gardera cependant en tête que la multiplication des niveaux introduit une augmentation de la taille du code et du temps de traitement.

Une fonction ne peut être appelée qu'à partir d'une autre fonction.

IV-G. Flux

Un flux est un canal de données orienté byte. Il permet de réaliser des entrées ou des sorties de bytes, soit un par un, soit par bloc.

C'est le mécanisme du langage C qui permet l'interaction avec l'environnement.

Un programme C ouvre 3 flux par défaut :

  • stdin : le flux d'entrée standard ;
  • stdout : le flux de sortie standard ;
  • stderr : le flux de sortie d'erreur.

Sur la plupart des implémentations, ces flux sont connectés à la console.

IV-H. Paramètre

La zone entre parenthèses d'une fonction peut recevoir des paramètres. Un paramètre est défini au minimum par un type et un identificateur.

Exemple :

Sélectionnez
int fonction (int x)
{
}

x est un paramètre de type int. Il est obligatoire, et la valeur passée doit être du même type.

Sélectionnez
...
{
   fonction (123);
}

...
{
   int a = 123;
   fonction (a);
}

Il faut savoir qu'en C, un paramètre ne fait que transmettre une valeur. Modifier la valeur d'un paramètre n'aura jamais d'effet sur la valeur originale. (la fonction printf() permet ici de visualiser la contenu des variables a et x) :

Sélectionnez
#include <stdio.h>

void fonction (int x)
{
   printf ("x = %d (dans la fonction)\n", x); /* x = 123 */

   x = 456;
   printf ("x = %d (dans la fonction)\n", x); /* x = 456 */
}

int main (void)
{
   int a = 123;

   printf ("a = %d\n", a); /* a = 123 */

   fonction (a);
   printf ("a = %d\n", a); /* a = 123 */
   return 0;
}

Après l'appel de la fonction, la valeur de 'a' est inchangée (123).

Sélectionnez
a = 123
x = 123 (dans la fonction)
x = 456 (dans la fonction)
a = 123

Le langage C permet la modification, mais en utilisant des techniques plus avancées.

IV-I. Préprocesseur

Le préprocesseur est un outil qui traduit certaines instructions du code source avant le compilateur. Les instructions du préprocesseur commencent par #. Par exemple #include.

IV-J. printf()

printf() est une fonction standard de la bibliothèque du la bibliothèque du C. Elle est conçue pour émettre des chaînes de caractères formatées vers la sortie standard (stdout). Elle est déclarée dans le fichier d'entête <stdio.h>. Pour l'utiliser, on doit utiliser la directive #include <stdio.h>.

Cette puissante fonction permet de réaliser des affichages formatés. Sa description complète prendrait un chapitre entier. Voici donc quelques éléments de base.

Elle fonctionne selon un principe un peu particulier :

Son premier paramètre est une chaîne de caractères pouvant comporter des séquences spéciales commençant par "%". Le caractère qui suit le "%" a une signification particulière. Certaines sont définies (%, d, i, f, s, c etc.), d'autres non. Le rôle est cette séquence de caractères est d'indiquer à printf() comment il doit interpréter les valeurs passées dans les paramètres suivants. Il doit y avoir correspondance, sinon, le comportement est indéfini.

IV-J-1. Les principaux formateurs de printf()

Formateur de base | Rôle | Type attendu | Types compatibles
% | affiche le glyphe du caractère % | int | short, char
c | affiche le glyphe d'un caractère imprimable | int | short, char
d | affiche la valeur d'un entier en décimal | int | short, char
f | affiche la valeur d'un réel en décimal avec virgule fixe | double | float
s | affiche les glyphes d'une chaîne de caractères | char * |

IV-J-2. Exemples d'utilisation de printf()

Sélectionnez
#include <stdio.h>

int main (void)
{
   printf ("%c\n", 'A');
   printf ("%d\n", 123);
   printf ("%d, '%c'\n", 'A', 'A');

   printf ("Hello world\n");
   printf ("Hello %s\n", "world");
   printf ("%s\n", Hello world);

   return 0;
}

produit :

Sélectionnez
A
123
65, 'A'
Hello world
Hello world
Hello world

Les possibilités complètes de cette puissante fonction sont décrites dans un livre de C comme ceux recommandés au début de ce chapitre.

IV-K. Prototype

Le prototype d'une fonction est une déclaration indiquant :

  • le type du retour (ou void si il n'y en a pas) ;
  • le nom de la fonction ;
  • la liste et le type des paramètres (ou void si il n'y en a pas).

Un prototype peut être intégré à la fonction. Dans ce cas, il est confondu avec la première ligne de la fonction :

Sélectionnez
int function (void)
{
   /* ... */
   return 0;
}

Il peut aussi être détaché (séparé) de la fonction. Dans ce cas, il doit être suivi d'un ';'.

Sélectionnez
int function (void);

IV-L. sizeof

L'opérateur unaire sizeof retourne la taille d'un objet en bytes. Le paramètre de sizeof peut être un objet ou un type. Si c'est un type, celui-ci doit être placé entre parenthèses. Le type retourné par sizeof est size_t (entier non signé). L'expression est une constante évaluée à la compilation.

size_t est un type standard défini dans <stddef.h>.

Sélectionnez
#include <stddef.h>

size_t size_of_an_int = sizeof (int);

double n;

size_t size_of_n = sizeof n;

IV-M. Tableau

Un tableau est une suite d'objets identiques consécutifs.

Sélectionnez
/* Définition d'un tableau de 10 char */
char tab[10];

Il est possible d'initialiser un tableau au moment de sa définition. Les membres non initialisés seront forcés à 0 :

Sélectionnez
/* Définition d'un tableau de 20 entiers
 * de type long avec initialisation a 0
 */
long tab[20] = {0};

/* Définition d'un tableau de 5 flottants
 * de type double avec initialisation partielle
 */
double tab[5] = {12.34, 56.78};

Enfin, il est possible de déterminer la taille d'un tableau par son initialisation. La taille s'ajuste alors en fonction du nombre d'initialiseurs :

Sélectionnez
/* Définition d'un tableau de 6 entiers
 * de type int initialises
 */
int tab[] = {1, 2, 3, 4, 5, 6};

Grâce à l'opérateur sizeof, il est possible de déterminer le nombre d'éléments d'un tableau. Il suffit de diviser la taille du tableau (en bytes) par la taille d'un élément du tableau (en bytes) (par exemple, [0], mais la valeur exacte de l'indice est sans importance).

Sélectionnez
/* Nombre d'éléments de 'tab' */
size_t nb_elem = sizeof tab / sizeof tab[0];

IV-N. Type

Le type est une des propriétés qui caractérise une valeur. Elle permet de préciser quelle est la gamme de valeurs possible. La norme qui définit le langage C précise qu'en fonction du type, ces valeurs doivent couvrir une gamme minimale.

Il y a quatre mots-clés du langage C qui définissent des types :

  • char : petit entier, convient pour les caractères ;
  • int : pour les valeurs numériques entières ;
  • float : nombres à virgule flottante en simple précision ;
  • double : nombres à virgule flottante en double précision.

On peut ajouter des mots-clés particuliers pour modifier la plage de valeurs (short, long) et / ou le fait que la valeur soit signée ou non (signed, unsigned), ce qui décale la plage de valeurs. L'ordre n'a pas d'importance, du moment que ces mots sont placés avant l'identificateur de type.

Voici les types entiers courants avec leurs gammes de valeurs minimales. Les termes entre [] sont facultatifs.

Type | Minimum | Maximum
char | 0 | 127
unsigned char | 0 | 255
signed char | -127 | 127
[signed] short [int] | -32767 | 32767
unsigned short [int] | 0 | 65535
[signed] int | -32767 | 32767
unsigned [int] | 0 | 65535
[signed] long [int] | -2147483647 | 2147483647
unsigned long [int] | 0 | 4294967295

Et uniquement en C99 :

Type | Minimum | Maximum
[signed] long long [int] | -9223372036854775807 | 9223372036854775807
unsigned long long [int] | 0 | 18446744073709551615

On peut mentionner aussi un type défini par l'implémentation, qui est size_t. C'est le type qui est retourné par l'opérateur sizeof. Ce type (entier non signé) est conçu pour contenir des informations du genre "taille d'un objet". Par conséquent, il convient aussi aux indices (croissants) des tableaux ainsi qu'aux nombres d'éléments des tableaux.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Bonnes pratiques de codage en langage C

  • I. Présentation du code source
    • I-A. Jeu de caractères
    • I-B. Indentation
      • I-B-1. Comment bien présenter le code source
      • I-B-2. Tabulations contre espaces
    • I-C. Commentaires
      • I-C-1. Comment bien gérer les commentaires
      • I-C-2. Comment bien commenter
    • I-D. Éditeur de texte
      • I-D-1. Bien choisir son éditeur de texte
  • II. Conventions de nommage
    • II-A. Utilisation du caractère '_' (Underscore)
    • II-B. Macros
    • II-C. Constantes
      • II-C-1. Conventions typographiques
      • II-C-2. Identificateur
      • II-C-3. Définition
    • II-D. Types
      • II-D-1. Conventions typographiques
    • II-E. Objets (Variables)
      • II-E-1. Conventions typographiques
      • II-E-2. Identificateur
    • II-F. Fonctions
      • II-F-1. Conventions typographiques
      • II-F-2. Identificateur
  • III. Organisation du code source
    • III-A. Fichiers sources (*.c)
      • III-A-1. Liste ordonnée des éléments pouvant être contenus dans un fichier source
    • III-B. Fichiers d'en-tête (*.h)
      • III-B-1. Règles d'or régissant la définition des fichiers d'en-tête
      • III-B-2. Liste ordonnée des éléments pouvant être contenus dans un fichier d'en-tête
      • III-B-3. Comment bien utiliser les séparateurs
      • III-B-4. Protection contre les inclusions multiples
  • IV. Comment bien organiser son développement
  • V. Comment construire sa bibliothèque
    • V-A. Outil de développement gcc
    • V-B. Création d'une bibliothèque sous Code::Blocks 8.02
  • VI. Comment bien configurer son compilateur
    • VI-A. gcc
    • VI-B. Réglages dans Code::Blocks
    • VI-C. Réglages dans wxDev-C++ (remplace Dev-C++ devenu obsolète)
    • VI-D. Microsoft Visual C++ (6, 2005, 2008 etc.)
    • VI-E. Borland C / Turbo C
  • VII. Ressources

Cet article est une synthèse de plusieurs notes autour des bonnes pratiques de codage à adopter en langage C.

I. Présentation du code source

Il est extrêmement facile d'écrire du code illisible en C, que ce soit par une mauvaise présentation, ou par un usage abusif des formes contractées.

Il est, par contre très utile, de présenter du code clair. Cela aide à la compréhension, à la relecture, au contrôle visuel, à la maintenance.

I-A. Jeu de caractères

Le jeu de caractères normalisé par le langage C est

Sélectionnez
  A B C D E F G H I J K L M
  N O P Q R S T U V W X Y Z
  a b c d e f g h i j k l m
  n o p q r s t u v w x y z
  0 1 2 3 4 5 6 7 8 9
  ! " # % & ' ( ) * + , - . / :
  ; < = > ? [ \ ] ^ _ { | } ~

plus l'espace, et les caractères de commande représentants :

  • le saut de ligne ;
  • le retour chariot ;
  • la tabulation horizontale ;
  • la tabulation verticale ;
  • le saut de page.

L'utilisation de caractères supplémentaires (accentués, par exemple) invoque un comportement défini par l'implémentation.

Dans la pratique, la plupart des compilateurs acceptent les extensions courantes comme IBM PC8 et ISO 8859-1 (aussi appelées respectivement OEM et ANSI dans le monde MS-DOS/Windows).

Personnellement, j'évite d'utiliser les caractères autres que les caractères standards dans un code source, y compris dans les commentaires.

I-B. Indentation

I-B-1. Comment bien présenter le code source

Il existe de nombreuses façons de présenter le code C. Certaines sont plus prisées que d'autres, et je n'entrerai pas dans ce débat. Par contre, il est important, une fois qu'on a décidé de ce qui était "bien", de s'y tenir.

Tout le code présenté sur ce site observe des règles homogènes de présentation. Un moyen simple d'y parvenir est d'utiliser un outil unique pour la mise en page (indentation), tel que GNU Indent. Cet outil dispose de nombreux paramètres que personnellement j'ai fixés une fois pour toute, et qui confèrent à mes codes sources une certaine unité.

Sélectionnez
indent -bli0 -npsl -i3 -ts0 -sob

I-B-2. Tabulations contre espaces

Il est fortement recommandé d'utiliser un éditeur qui permet de remplacer les tabulations par des espaces (l'idéal étant que le nombre soit programmable). En effet, l'effet d'une tabulation n'est pas uniforme, alors que celui d'un espace l'est certainement d'avantage (à part en HTML, mais il y a des contournements possibles).

I-C. Commentaires

I-C-1. Comment bien gérer les commentaires

Le langage C, dans sa version normalisée ISO-1990, ne supporte que les commentaires de type /* comment */. L'imbrication des commentaires n'est pas autorisée.

Il est préférable d'éviter de placer des commentaires en bout de ligne. En effet, certains périphériques ou applications limitent la largeur visible du texte à moins de 80 colonnes. Ce serait dommage de se priver de commentaires qui peuvent être utiles. Il est donc recommandé de placer ses commentaires au-dessus de la portion de code à commenter.

Sélectionnez
/* compteur */
int cpt ;

Si un commentaire doit utiliser plusieurs lignes, il est recommandé d'utiliser la présentation 'ouverte', qui facilite la maintenance, tout en préservant la lisibilité. Exemple de forme minimale :

Sélectionnez
/* Ce compteur doit être initialise
   avant utilisation */
int cpt ;

S'il faut isoler provisoirement une portion de code, le mieux est de ne pas utiliser les commentaires (il pourrait y avoir des commentaires imbriqués), mais plutôt les directives du préprocesseur : #ifdef .. #endif ou #if .. #endif

Sélectionnez
#if 0
  /* Compteur */
  int cpt ;
#endif

I-C-2. Comment bien commenter

Le moins possible !

Le principe est de ne commenter que ce qui apporte un supplément d'information. Il est d'usage d'utiliser en priorité l'auto-documentation, c'est à dire un choix judicieux des identificateurs qui fait que le code se lit 'comme un livre'…

I-D. Éditeur de texte

I-D-1. Bien choisir son éditeur de texte

L'outil avec lequel un programmeur passe probablement le plus de temps est son éditeur de texte. Il entre dans une part importante de la productivité du programmeur et de la présentation du code.

Je n'entrerai évidemment pas dans le débat futile Vi/Emacs, car je mets tout le monde d'accord avec UltraEdit32 ! En fait peu importe, pourvu qu'il dispose au moins des fonctions suivantes :

  • police à chasse fixe ;
  • coloration syntaxique ;
  • la touche TAB produit des espaces (programmable, pour moi, c'est 3) ;
  • édition en mode colonne ;
  • annulation ;
  • copie de sauvegarde ;
  • patrons avec fonctions de base comme le nom du fichier et la date de création ;
  • possibilité d'ajouter des outils externes pour compléter ce qui manque.

II. Conventions de nommage

Une des façons d'obtenir du code clair est de s'en tenir à une convention de nommage des identificateurs qui soit cohérente et parlante. Voici mes recommandations.

II-A. Utilisation du caractère '_' (Underscore)

Ce caractère peut être utilisé comme séparateur visuel dans les identificateurs. Bien que ce soit techniquement possible, Je recommande de ne pas l'utiliser au début d'un identificateur, pour deux raisons principales :

  • La plupart de ces identificateurs sont réservés à l'implémentation du langage
  • L'aspect très inesthétique de ces identificateurs

II-B. Macros

Les macros ayant des usages multiples, il est difficile de faire une généralité. Dans les cas où elles représentent une constante, il est recommandé d'utiliser les majuscules. Dans les autres cas, le choix sera le même que pour l'identificateur qu'elles substituent. Cependant, si on cherche à insister sur le fait qu'une soi-disant fonction est en fait une macro, on peut utiliser les majuscules. En fait tout dépend du contexte.

II-C. Constantes

II-C-1. Conventions typographiques

Il est couramment admis que les constantes 'vraies' (macros, énumérations) doivent être écrites en majuscules. Les variables qualifiées const seront écrites comme des variables.

II-C-2. Identificateur

L'identificateur dépend du contexte. Il sert souvent à définir les valeurs des propriétés d'un objet (valeurs particulières, états, etc.). Il est recommandé d'utiliser un préfixe qui relie les constantes qui qualifient une même propriété.

Sélectionnez
  /*
  * Valeurs possibles de l'état du voyant
  *
  * OFF   : éteint
  * ON    : allume fixe
  * BLINK : clignotant
  */
  enum
  {
    VOYANT_STS_OFF,
    VOYANT_STS_ON,
    VOYANT_STS_BLINK,

    VOYANT_STS_NB
  };

II-C-3. Définition

Il y a deux façons de créer des valeurs constantes. Avec une macro ou avec une énumération.

Pour les constantes numériques de type int, il est recommandé d'utiliser les enum, qui présentent des avantages comme le typage, la numérotation automatique, ou l'interprétation textuelle automatique dans les débogueurs.

Pour les constantes numériques des autres types (long, unsigned etc.) et les chaînes, il n'y a pas le choix, il faut utiliser les macros. Ne pas oublier les qualificateurs de macros pour les valeurs numériques autres que int : Suffixes de macros numériques u ou U unsigned :

l ou L | long
ul ou UL | unsigned long
f ou F | float

Ne pas oublier non plus que pour qu'une constante soit de type double, au moins un des membres doit être de type double, donc comprendre un '.' dans son expression :

Sélectionnez
  #define PI (22/7)

est un entier qui vaut 3.

Sélectionnez
  #define PI (22/7.0)

est un double qui vaut environ 3.14.

II-D. Types

Le langage C permet de créer un alias sur un type existant plus ou moins complexe ou un autre alias existant à l'aide du mot clé typedef.

II-D-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules. Il est recommandé d'utiliser des suffixes tels que :

Suffixes de types (alias)
_e | typedef enum
_u | typedef union
_s | typedef struct
_a | tableau (array)
_f | fonction

Il est recommandé de ne pas utiliser le suffixe '_t', car il est réservé par la norme POSIX pour définir des alias de types.

II-E. Objets (Variables)

II-E-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules.

II-E-2. Identificateur

Il est un principe général de bonne conception qui veut que la longueur des identificateurs des objets et des fonctions soit proportionnelle à leur portée.

Les variables introduisent en plus la notion de durée de vie (duration). Il est bon qu'au premier coup d'œil, on sache exactement à quoi on a affaire. Il est bon de pouvoir aussi identifier rapidement les variables en fonction de leurs propriétés. Les plus remarquables sont :

  • simple (plain) ;
  • pointeur ;
  • tableau statique ;
  • tableau dynamique ;
  • chaîne de caractères.

Il est donc recommandé d'utiliser les préfixes suivants :

Préfixes d'identificateurs
 | simple
p_ | pointeur
a_ ou sa_ | tableau statique(static array)
da_ | tableau (dynamic array)
s_ | chaîne de caractères (string)

et en ce qui concerne la durée de vie et la portée :

Préfixes indiquant la durée de vie et la portée
 | Portée | Durée de vie
 | bloc | bloc
g_ | bloc | programme
S_ | module | programme
G_ | programme | programme

II-F. Fonctions

II-F-1. Conventions typographiques

Il est d'usage d'utiliser les minuscules. Il existe des cas où l'on a besoin de 2 mots pour nommer une fonction. Il n'est évidemment pas question d'accoler ces deux mots en minuscule, car le code serait vite illisible. Il existe 2 pratiques répandues :

  • coller les mots en mettant une majuscule au début de chaque mot :
Sélectionnez
OuvrirFichier()

ou (variante «à-la-Java») à partir du 2ème mot :

Sélectionnez
ouvrirFichier()
  • séparer les mots en mettant un souligné (underscore) entre chaque mot.
Sélectionnez
ouvrir_fichier()

C'est une question de goût, le principal est d'être clair et cohérent. La seconde méthode a ma préférence.

II-F-2. Identificateur

Pour une fonction, on choisit plutôt un identificateur qui exprime une action (verbe, verbe + substantif).

III. Organisation du code source

Il est pratique d'adopter une disposition logique et cohérente dans les fichiers sources et d'en-têtes. Il est d'usage d'utiliser un principe simple, qui consiste à définir ce que l'on doit utiliser. C'est pourquoi la disposition suivante est recommandée :

III-A. Fichiers sources (*.c)

III-A-1. Liste ordonnée des éléments pouvant être contenus dans un fichier source

  • inclusion des en-têtes (.h) nécessaires ;
  • définition des macros privées ;
  • définition des constantes privées ;
  • définition des types privés ;
  • définition des structures privées ;
  • définition des variables globales privées ;
  • définition des fonctions privées ;
  • définition des fonctions publiques ;
  • définition des variables globales publiques.

Voici par exemple, une liste de séparateurs utilisés pour structurer des fichiers sources :

Sélectionnez
  /* macros ============================================================== */
  /* constants =========================================================== */
  /* types =============================================================== */
  /* structures ========================================================== */
  /* private variables =================================================== */
  /* private functions =================================================== */
  /* internal public functions =========================================== */
  /* entry points ======================================================== */
  /* public variables ==================================================== */

III-B. Fichiers d'en-tête (*.h)

III-B-1. Règles d'or régissant la définition des fichiers d'en-tête

  • un fichier d'en-tête doit être protégé contre les inclusions multiples dans la même unité de compilation ;
  • un fichier d'en-tête doit être autonome[1] ;
  • un fichier d'en-tête ne doit inclure que le strict nécessaire à son autonomie.

[1] Pour s'en assurer, lors des tests de l'implémentation correspondante, le placer en 1ère position et ajouter ce qu'il manque dedans.

III-B-2. Liste ordonnée des éléments pouvant être contenus dans un fichier d'en-tête

  • inclusion des headers nécessaires et suffisants ;
  • définition des macros publiques ;
  • définition des constantes publiques ;
  • définition des types publiques ;
  • définition des structures publiques ;
  • déclaration des fonctions publiques ;
  • déclaration des variables globales publiques ;
  • [C99] définition des fonctions publiques inline.

Voici par exemple, une liste de séparateurs utilisés pour structurer des fichiers d'en-tête :

Sélectionnez
  /* macros ============================================================== */
  /* constants =========================================================== */
  /* types =============================================================== */
  /* structures ========================================================== */
  /* internal public functions =========================================== */
  /* entry points ======================================================== */
  /* public variables ==================================================== */
  /* inline public functions  ============================================ */

III-B-3. Comment bien utiliser les séparateurs

A l'origine, on a un fichier avec main() (par exemple main.c, comme ça, on voit tout de suite de quoi on parle). main() est le point d'entrée (unique) de ce module. On peut donc écrire :

Sélectionnez
/* main.c */
#include

/* entry points ======================================================== */

int main (void)
{
   puts ("hello world");

   return 0;
}

Le programme évoluant, on a maintenant une fonction privée (static) :

Sélectionnez
/* main.c */
#include

/* private functions =================================================== */

static void hello(void)
{
   puts ("hello world");
}

/* entry points ======================================================== */

int main (void)
{
   hello();

   return 0;
}

Nouvelle évolution, on crée un bloc fonctionnel (BF) hello composé pour le moment d'une seule fonction. On retrouve donc la structure habituelle avec 3 fichiers :

Sélectionnez
#ifndef H_HELLO
#define H_HELLO
/* hello.h */

/* entry points ======================================================== */

void hello(void);
Sélectionnez
/* hello.c */
#include "hello.h"
#include

/* entry points ======================================================== */

void hello(void)
{
   puts ("hello world");
}
Sélectionnez
/* main.c */
#include "hello.h"

/* entry points ======================================================== */

int main (void)
{
   hello();

   return 0;
}

On fait dans le luxe et on décompose la fonction hello() en plusieurs petites fonctions (un peu théorique, mais c'est pour montrer le principe).

Sélectionnez
/* hello.c */
#include "hello.h"
#include

/* private functions =================================================== */

static void h(void)
{
   printf ("hello");
}

static void w(void)
{
   printf ("world");
}

static void spc(void)
{
   putchar (' ');
}

static void eol(void)
{
   putchar ('\n');
}

/* entry points ======================================================== */

void hello(void)
{
   h();
   spc();
   w();
   eol();
}

Le reste (interface, allocation) est inchangé.

Attention, étape ultime et subtile.

Le fichier d'implémentation de hello est devenu tellement gros qu'il nécessite lui-même un découpage. Il va donc falloir distinguer ce qui est accessible à l'utilisateur de hello (les points d'entrées) et les fonctions 'internes', c'est à dire connues de hello mais pas de l'application, d'où cette distinction, qui se traduit aussi par des headers distincts :

Sélectionnez
/* hellotools.c */
#include "hellotools.h"
#include

/* internal public functions =========================================== */
void hello_spc(void)
{
   putchar (' ');
}

void hello_eol(void)
{
   putchar ('\n');
}
Sélectionnez
#ifndef H_HELLOTOOLS
#define H_HELLOTOOLS
/* hellotools.h */

/* internal public functions =========================================== */
void hello_spc(void);
void hello_eol(void);

#endif
Sélectionnez
/* hello.c */
#include "hellotools.h"
#include "hello.h"

#include

/* private functions =================================================== */
static void h(void)
{
   printf ("hello");
}

static void w(void)
{
   printf ("world");
}

/* entry points ======================================================== */
void hello(void)
{
   h();
   hello_spc();
   w();
   hello_eol();
}

Cela revient à créer plusieurs niveaux de visibilité…

III-B-4. Protection contre les inclusions multiples

Les fichiers d'en-tête pouvant être inclus dans des fichiers sources, comme dans des fichiers d'en-tête, et ce, dans un ordre non spécifié, il est indispensable de se prémunir contre les risques d'inclusions multiples dans la même unité de compilation.

Sélectionnez
  #ifndef IDENTIFICATEUR
  #define IDENTIFICATEUR

  /* zone protégée contre les inclusions multiples */

  #endif /* guard */

Le principe de protection étant basé sur la définition d'une macro, cette macro doit être unique afin d'éviter les protections abusives.

Personnellement, j'utilise le format suivant :

Sélectionnez
  H_<initials>_<file>_<date>
  initials ::= En majuscule : initiales du développeur, nom de la société etc.
  file     ::= En majuscule : nom du fichier (sans l'extension)
  date     ::= <year><month><day><hour><minute><second>
  year    ::= année (0000-9999)
  month   ::= mois (01-12)
  day     ::= jour (01-31)
  hour    ::= heure (00-23)
  minute  ::= minute (00-59)
  second  ::= seconde (00-59)

Par exemple :

Sélectionnez
  H_ED_EXEMPLE_20040529115235

Le choix de mettre le H_ en tête est justifié. En effet, il évite de briser une règle du langage C qui dit qu'un identificateur commençant par E suivi d'une majuscule est réservé pour l'implémentation. En fait ils sont utilisés pour implémenter les valeurs symboliques de errno.

IV. Comment bien organiser son développement

Le langage C autorise la compilation séparée. Cette technique permet de créer des unités de compilations (compile units) séparées. Une bonne maîtrise de la programmation permet de réaliser du code indépendant ou tout au moins suffisamment indépendant pour être testable individuellement.

Voici un exemple élémentaire de compilation séparée reprenant le célèbre "Hello world!". Le code est divisé en 3 sources. Un fichier d'interface (hello.h), un fichier d'implémentation (hello.c) et un fichier d'application (main.c) :

main.c
Sélectionnez
  /* main.c */

  #include "hello.h"

  int main (void)
  {
    hello ();
    return 0;
  }
hello.h
Sélectionnez
  #ifndef H_HELLO
  #define H_HELLO

  /* hello.h */

  void hello (void);

  #endif /* guard */
hello.c
Sélectionnez
  /* hello.c */

  #include "hello.h"
  #include <stdio.h>

  void hello (void)
  {
    puts ("Hello world!");
  }

Vidéo sonore (flash) expliquant comment faire sous Code::Blocks 8.02

Afin de bien organiser les fichiers, je conseille de créer des répertoires pour les sources et pour les fichiers de sortie :

Organisation des répertoires
Type | Répertoire | Fichiers | nom.ext
Génération | ./ | make | Makefile
Source | inc/ | inclus | *.h
Source | src/ | source | *.c
Sortie | obj/ | objets intermédiaires | *.obj
Sortie | exe/ | exécutable | *.exe

et par conséquent, d'adopter l'organisation suivante pour les répertoires :

Sélectionnez
  hello/Makefile
  hello/inc/hello.h
  hello/src/hello.c
  hello/src/main.c
  hello/obj/hello.obj
  hello/obj/main.obj
  hello/exe/hello.exe

Afin de garantir un comportement correct du code et des outils de développement, je recommande que les noms de répertoires et de fichiers soient tous écrits en minuscule. Je rappelle que si on utilise un outil de gestion de version comme CVS, il est extrêmement difficile de modifier la casse des répertoires et même des fichiers après coup. C'est donc une démarche à adopter dès la première ligne de code.

Pour compiler et exécuter ce code sous gcc avec cette organisation, on peut utiliser ce Makefile (DJGPP). (Remplacer <tab> par une véritable tabulation) :

Sélectionnez
  # Makefile HELLO

  # Paths
  DSRC = src
  DINC = inc
  DOBJ = obj
  DEXE = exe
  DLIB = d:/djgpp/lib

  # Compiler flags
  CFLAGS = -I$(DINC)

  # Commands
  CC = gcc -c $(CFLAGS)
  LK = ld
  RM = del

  # Project list
  OBJS =      \
  $(DOBJ)/hello.obj  \
  $(DOBJ)/main.obj   \

  #Rebuild all target
  all: $(DEXE)/hello.exe

  # Clean all target
  clean:
  <tab>cd $(DOBJ)
  <tab>$(RM) *.obj
  <tab>cd ../$(DEXE)
  <tab>$(RM) *.exe
  <tab>cd ..

  # main target (OBJS + init code + library)
  $(DEXE)/hello.exe : $(OBJS)
  <tab>$(LK) -o $(DEXE)/hello.exe $(OBJS) $(DLIB)/crt0.o -lc

  # objects

  # The hello.c file
  $(DOBJ)/hello.obj : $(DSRC)/hello.c          \
            $(DINC)/hello.h           \
            Makefile
  <tab>$(CC) $(DSRC)/hello.c -o$(DOBJ)/hello.obj

  # The main.c file
  $(DOBJ)/main.obj : $(DSRC)/main.c            \
            $(DINC)/hello.h           \
            Makefile
  <tab>$(CC) $(DSRC)/main.c -o$(DOBJ)/main.obj

Avec une utilisation comme suit (MS-DOS) :

  • génération ou mise à jour :
Sélectionnez
  D:\HELLO>make
  gcc -c -Iinc src/hello.c -oobj/hello.obj
  gcc -c -Iinc src/main.c -oobj/main.obj
  ld -o exe/hello.exe obj/hello.obj obj/main.obj  d:/djgpp/lib/crt0.o -lc
  • effacement complet :
Sélectionnez
  D:\HELLO>make clean
  cd obj
  del *.obj
  cd ../exe
  del *.exe
  cd ..

Pour en savoir plus, consultez l'article l'outil MAKE

V. Comment construire sa bibliothèque

Le langage C utilisant lui même la notion de bibliothèque de fonctions, il est logique que cette possibilité soit offerte aux développeurs. Les avantages sont bien connus :

  • identification et bonne gestion du code réutilisable ;
  • interface bien défini (headers) ;
  • gain de productivité (réutilisation,moins de code à compiler).

Les détails de réalisation dépendent des outils de développement, mais le principe d'organisation du code est le même. La compilation d'une bibliothèque est évidemment une application directe du principe de la compilation séparée.

S'ajoutent quelques règles de bon sens telles que l'indépendance du code vis à vis de l'application. Cela concerne particulièrement les sorties qui, si il le faut, seront implémentées par des 'callbacks' (détails dans cet article : Concevoir et réaliser un composant logiciel en C ). Plus que jamais, les globales seront évitées.

Pour réaliser la bibliothèque, il faut créer un projet rassemblant les fichiers d'interface (headers : .h) et les fichiers d'implémentation (sources : .c). Évidemment, il ne doit pas y avoir de fonction main().

Ensuite, après compilation classique, et sans faire d'édition de lien, bien évidemment, on utilise un 'librarian' qui est un outil particulier faisant partie des outils de développements courants, et qui sert à créer la bibliothèque. Le résultat est un fichier dont l'extension dépend de l'outil. (par exemple, .lib ou .a).

V-A. Outil de développement gcc

Tout d'abord, avec gcc, il y a des règles de nommage à respecter. En effet, le nom du fichier produit doit impérativement commencer par lib et son extension doit être .a (comme archive).

Ensuite, l'outil s'appelle ar (ar.exe sous DOS/Windows) comme archiver. Les détails d'appels en mode commande sont à lire dans la documentation de gcc. Comme toujours en mode ligne de commande, un Makefile simplifie et automatise la tâche de production du code.

Les exemples suivants utilisent le fichier d'interface hello.h et le fichier d'implémentation hello.c décrits dans l'article sur la compilation séparée. La bibliothèque à créer s'appelle libhello.a. Elle ne sert évidemment qu'à illustrer le propos.

V-B. Création d'une bibliothèque sous Code::Blocks 8.02

  • File > New > Project ;
  • sélectionner 'static library' ;
  • sélectionner un répertoire <racine> et taper le nom de la bibliothèque : libhello /!\ IMPORTANT /!\ ;
  • le répertoire <racine>/libhello est créé automatiquement ; c'est le répertoire de travail ;
  • Debug et Release sont cochées, ne rien changer ;
  • cliquer sur [finish].

Dans le 'workspace', une ligne en gras a été ajoutée : 'libhello'. Un fichier main.c a été créé. On peut le retirer du projet :

  • Clic droit > Remove from project

Il faut maintenant ajouter les 2 fichiers source (.c et .h). Si il n'y sont pas déjà, les déplacer (ou les créer) dans le répertoire du projet (<racine>/libhello), puis :

  • sur la ligne 'Biblio. Hello' : click droit ;
  • Add files ;
  • vérifier qu'on est bien dans le répertoire <racine>/libhello ;
  • (on en profite pour supprimer main.c) ;
  • sélectionner hello.h et hello.c ;
  • valider ;
  • compiler.

La fenêtre de compilation doit donner quelque chose comme ceci :

Sélectionnez
-------------- Build: Debug in libhello ---------------

Compiling: hello.c
Linking static library: libhello.a
ar.exe: creating libhello.a
Output size is 3.63 KB
Process terminated with status 0 (0 minutes, 0 seconds)
0 errors, 0 warnings

On voit que hello.c a été compilé, que ar.exe a été invoqué et que libhello.a a été crée. On peut maintenant créer un petit programme de test avec le main.c et la bibliothèque. Le plus simple pour le moment est de la créer dans le même répertoire.

Sélectionnez
  Project   : Test hello
  Compiler  : GNU GCC Compiler (called directly)
  Directory : C:\dev\hello\
  --------------------------------------------------------------------------------
  Switching to target: default
  Compiling: main.c
  Linking console executable: C:\dev\hello\test.exe
  .objs\main.o: In function `main':
  C:/dev/hello/main.c:7: undefined reference to `hello'
  collect2: ld returned 1 exit status
  Process terminated with status 1 (0 minutes, 2 seconds)
  1 errors, 0 warnings

Bien sûr, le main.c tout seul ne suffit pas, il faut ajouter la bibliothèque au projet :

  • sur la ligne 'Test biblio. Hello' : clic droit ;
  • build options > Linker > Add > [...] > libhello.a > Ouvrir > NON > OK > OK ;
  • compiler.

On obtient alors :

Sélectionnez
  Project   : Test hello
  Compiler  : GNU GCC Compiler (called directly)
  Directory : C:\dev\hello\
  --------------------------------------------------------------------------------
  Switching to target: default
  Linking console executable: C:\dev\hello\test.exe
  Process terminated with status 0 (0 minutes, 0 seconds)
  0 errors, 0 warnings

et le fameux

Sélectionnez
  Hello world!

  Press ENTER to continue.

tant espéré !

VI. Comment bien configurer son compilateur

Le compilateur dispose de moyens d'analyse du code qui peuvent être mis à profit pour détecter toutes sortes d'erreurs. Il est donc important de savoir configurer son compilateur pour qu'il signale au mieux les anomalies pouvant se produire dans le code (Warnings). C'est ensuite au programmeur de réagir et, soit de justifier ou d'expliquer l'avertissement, soit de corriger le code.

La première des vérifications à faire est de s'assurer que l'on compile avec le bon compilateur. Certaines extensions ou réglages par défaut font que c'est parfois le compilateur C++ qui est invoqué au lieu du compilateur C. Ces quelques lignes placées au début de chaque fichier source (.c) permettent de détecter cette erreur :

Sélectionnez
#ifdef __cplusplus
#error This source file is not C++ but rather C. Please use a C-compiler
#endif

Si l'erreur se produit, vérifier les réglages et l'extension. Celle-ci doit impérativement être .c (minuscule), et non .cpp, ni .C (majuscule)

VI-A. gcc

gcc est le 'gnu c compiler', version GNU du cc Unix pour GNU/Linux. Il a été porté sur de nombreuses plate-formes dont Windows (les plus connues sont MinGW et CygWin).

Par défaut, le niveau d'avertissement (Warnings) est très laxiste. Il est fortement recommandé d'utiliser la configuration minimale suivante (C90) :

Sélectionnez
-Wall -Wextra -ansi -O -Wwrite-strings -Wstrict-prototypes -Wuninitialized
-Wunreachable-code

Sur les anciennes version de gcc (< 3.4.x), remplacer -Wextra par -W.

Si la version de gcc le permet, il est possible d'ajouter ces options :

Sélectionnez
-Wno-missing-braces  -Wno-missing-field-initializers

Experts uniquement

Il est aussi possible de procéder à un réglage 'fin'. Voici comment je configure gcc en mode 'paranoïaque' (C90) :

Sélectionnez
-O2 -Wchar-subscripts -Wcomment -Wformat=2 -Wimplicit-int
-Werror-implicit-function-declaration -Wmain -Wparentheses
-Wsequence-point -Wreturn-type -Wswitch -Wtrigraphs -Wunused
-Wuninitialized -Wunknown-pragmas -Wfloat-equal -Wundef
-Wshadow -Wpointer-arith -Wbad-function-cast -Wwrite-strings
-Wconversion -Wsign-compare -Waggregate-return -Wstrict-prototypes
-Wmissing-prototypes -Wmissing-declarations -Wmissing-noreturn
-Wformat -Wmissing-format-attribute -Wno-deprecated-declarations
-Wpacked -Wredundant-decls -Wnested-externs -Winline -Wlong-long
-Wunreachable-code

Attention ce réglage révèle certains défauts dans les headers de MinGW. La correction est possible, mais uniquement si on sait ce qu'on fait. Je n'en dirai donc pas plus ici.

Le détail de chaque paramètre est expliqué dans la documentation de gcc.

VI-B. Réglages dans Code::Blocks

  • Settings > Compiler > Onglet 'Compiler' > Onglet 'Other options' ;
  • copier/coller les options ;
  • OK ;
  • régénérer (Ctrl-F11).

S'assurer qu'une coche ne traine pas dans l'onglet de configuration du compilateur.

Plus d'informations sur Code::Blocks.

VI-C. Réglages dans wxDev-C++ (remplace Dev-C++ devenu obsolète)

  • outils > Options du compilateur ;
  • dans la zone de saisie "Ajouter les commandes suivantes lors de l'appel du compilateur", copier/coller les options que je recommande ;
  • valider ;
  • régénérer (Ctrl-F11).

VI-D. Microsoft Visual C++ (6, 2005, 2008 etc.)

  • propriétés du projet -> C/C++ -> général ;
  • mettre le niveau (level) de warning à 4.

Plus d'informations sur Microsoft Visual C++ Express 2008 et 2010

VI-E. Borland C / Turbo C

  • Option -> Compiler -> Messages -> Display ;
  • (*) ALL.

VII. Ressources

  • Recommended C Style and Coding Standards ;
  • MISRA ;
  • Macros prédéfinies.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Bibliothèque CLIB

  • I. Présentation
  • II. Contenu

Présentation de la bibliothèque CLIB écrite en langage C.

Cette bibliothèque est le fruit de plus de 10 ans de développement d'applications embarquées dans le domaine des réseaux numériques.

I. Présentation

Cette bibliothèque est le fruit de plus de 10 ans de développement d'applications embarquées dans le domaine des réseaux numériques. Elle est écrite en C-ISO 90 (sauf mention contraire).

La bibliothèque est basée sur le répertoire clib. Il est recommandé de respecter l'arborescence suivante :

Sélectionnez

whatever/clib/ed/inc : .h, .itm

whatever/clib/ed/src : .c

et de définir un chemin de base pour le compilateur :

Sélectionnez

-Iwhatever/clib

II. Contenu

Module | Description | Fichiers | Compatibilité | Tests unitaires
ASCII | Conversions ASCII Texte. Suppression accents | ascii.c ascii.h ansi.itm | ISO-C 90
ASSERT Macro ASSERT() avec sortie par exit()...
Évite les plantages des sorties par abort(). 		assert.c assert.h ISO-C 90
BITS | Constantes et macros bits | bits.h | ISO-C 90
BUF | Gestion d'un bloc {adresse, longueur} | buf.c buf.h | ISO-C 90
BUFSZ | Gestion d'une chaîne C (terminée par un 0) | bufsz.c bufsz.h | ISO-C 90
BLK | Gestion d'un bloc générique (ADT). | blk.c blk.h blk_err.itm | ISO-C 90
BOOL | Constantes et type booléen | bool.h | ISO-C 90
C64 | Compteur 64-bit | c64.c c64.h | ISO-C 90
CHRO | Mesure du temps d'exécution | chro.c chro.h chro_err.itm | ISO-C 90
CI Interpréteur de commande basique (Command Interpretor)
Manuel utilisateur 		ci.c ci.h ci_err.itm ISO-C 90 main.c app.h app.c
CNT | Compteurs | cnt.c cnt.h cnt_err.itm | ISO-C 90
CS8 Checksum 8-bit
Manuel utilisateur 		cs8.c cs8.h ISO-C 90
DATE | Gestion des dates | date.c date.h | ISO-C 90
DIR | Gestion des répertoires | dir.c dir.h | POSIX.1 | main.c
FARR | Gestion flexible d'un tableau générique (ADT). | farr.c farr.h farr_err.itm | ISO-C 90 | frmwrk.c main.h tfarr.c
FIC Opérations sur les fichiers fic.c fic.h fic_err.itm ISO-C 90
(sauf 2 fonctions) 		frmwrk.c main.h main.c
FIFO | Gestion de FIFO bytes | fifo.c fifo.h | ISO-C 90
FIFOX | Extension de FIFO (tests) | fifox.c fifox.h | ISO-C 90
FSM Gestionnaire d'automates (Finite State Machine)
Manuel utilisateur 		fsm.c fsm.h fsm_err.itm ISO-C 90
FSTR | Gestion flexible d'une chaîne (ADT). | fstr.c fstr.h fstr_err.itm | ISO-C 90 | frmwrk.c main.h tfstr.c
GFIFO | FIFO générique | gfifo.c gfifo.h | ISO-C 90 | frmwrk.c main.h main.c
GLL | Gestion d'une liste doublement chaînée générique | gll.c gll.h gll_err.itm | ISO-C 90 | tgll.c
HIS | Gestion d'un histogramme (texte) | his.c his.h his_err.itm | ISO-C 90
HTON | Interface Host / Network | hton.c hton.h | Selon la cible | test.c
INI | Gestionnaire de fichier .INI (configuration) | ini.c ini.h ini_err.itm | ISO-C 90
IO | Gestionnaire d'entrées / sorties | io.c io.h | ISO-C 90 | frmwrk.c main.h main.c
IPV4 | Outils pour IP V4 | ipv4.c ipv4.h | ISO-C 90
ITOA | Fonctions de conversion numérique -> texte | itoa.c itoa.h | ISO-C 90
MEM | Définitions divers pour les outils de gestion de la mémoire | mem.h | ISO-C 90
MF | Algorithme d'acquisition de multitrame de signalisation G.704 | mf.c mf.h | ISO-C 90
MUSR | Gestionnaire de partage d'une ressource (Multiuser) | musr.c musr.h musr_err.itm musr_tra.itm | ISO-C 90
OBJ | Gestionnaire d'objet générique | obj.c obj.h obj_err.itm | ISO-C 90
OPT | Gestionnaire de paramètres optionnels de la ligne de commande | opt.c opt.h opt_err.itm | ISO-C 90
PC_DBG | (ici, inactif, mais inclus par certains headers) | pc_dbg.h | ISO-C 90
PI Définition de PI (M_PI) pi.h ISO-C 90
(flottants)
PRG | Générateur pseudo aléatoire | prg.c prg.h | ISO-C 90
PRT | Afficher le contenu d'une variable (debug) | prt.h | ISO-C 90
Q | Liste chaînée simple générique | q.c q.h | ISO-C 90
RA | Allocateur de ressource (Resource Allocator) | ra.c ra.h ra_err.itm | ISO-C 90
RA_DBG | Aide à la mise au point de RA | ra_dbg.c ra_dbg.h | ISO-C 90
RANDOM | reprise des fonctions random() et randomize() de Borland | random.h | ISO-C 90
REC | Assembleur de ligne de texte (réception texte) | rec.c rec.h | ISO-C 90
S | Gestionnaire de format S | s.c s.h srecord.itm | ISO-C 90
STAT | Statistiques simples (comptage, min, max) | stat.c stat.h | ISO-C 90
STR | Outils pour les chaînes de caractères | str.c str.h | ISO-C 90
SYS | Divers outils généraux | sys.c sys.h sys_ver.c sys_ver.h | ISO-C 90
SYSALLOC Wrapper de malloc()/free(). Mise au point gestion mémoire
Mode d'emploi 		sysalloc.c sysalloc.h ISO-C 90
TIM | Timer | tim.c tim.h | ISO-C 90 | ttim.c
TOK | Tokenizer de texte | tok.c tok.h tok_err.itm | ISO-C 90 | ttok.c
TYPES | Définitions de types courants | types.h | ISO-C 90
VT100 | Driver console VT-100 [Fred P. for J2K Library LGPL Licensed.] | vt100.c vt100.h | ISO-C 90

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Concevoir et réaliser un composant logiciel en C

  • I. Introduction
  • II. Stratégie de développement d'un composant logiciel
    • II-A. Modélisation d'un composant logiciel
    • II-B. Implémentation du modèle.
  • III. Étude de cas : Un gestionnaire d'alarme à niveaux
    • III-A. Spécifications
    • III-B. Conception
    • III-C. Réalisation en C
      • III-C-1. Nommage du composant logiciel
      • III-C-2. Interface
      • III-C-3. Implémentation

Cet article présente une stratégie de développement de modules de logiciels dont le comportement est clairement spécifié, les performances et les limites connues, et qui soient facilement intégrables.

I. Introduction

Lors du développement d'un projet logiciel, il est courant d'écrire des portions de codes qui pourraient être utiles dans d'autres projets. Pour ce faire, il faut définir une stratégie de développement de modules de logiciels dont le comportement est clairement spécifié, les performances et les limites connues, et qui soient facilement intégrables.

Ce type de module logiciel est appelé 'composant logiciel', par analogie avec les composants utilisés dans l'industrie de l'électronique, par exemple.

II. Stratégie de développement d'un composant logiciel

Les qualités requises pour un composant logiciel sont :

  • une spécification claire :
    • comportement ;
    • performances ;
    • interface (entrées, sorties) ;
    • gestion des erreurs ;
  • une grande fiabilité :
    • respect des normes du langage ;
    • respect des normes de codage ;
    • vérification du domaine des paramètres ;
    • élimination des pratiques douteuses ;
    • testabilité (indépendance vis à vis des sorties) ;
    • jeu de tests de non régression.
  • une facilité d'intégration :
    • portabilité ;
    • respect des normes de nommage ;
    • élimination des pratiques empêchant la réentrance ;
    • élimination des pratiques empêchant l'instanciation ;
    • simplicité des points de sorties.

II-A. Modélisation d'un composant logiciel

L'expérience montre que la plupart des composants logiciels servent à effectuer un traitement sur des données.

Sélectionnez
              ---------------
  Entrées    |               | Résultat
  ---------> |  Traitements  | ----------->
             |               |
              ---------------

Certains événements extérieurs peuvent agir sur les données (entrées). Il est bien sûr possible de fournir un résultat immédiat par valeur de retour ou par un paramètre de sortie, mais certains traitements peuvent aussi donner lieu à des réactions en fonction des résultats et/ou de l'état courant (seuil, erreur etc.). Ce sont les sorties.

On peut donc modéliser un composant logiciel de la façon suivante :

Sélectionnez
    ----------------------------------------
   |          Composant logiciel            |
   |----------------------------------------|
   |                                        |
   |             ---------------            |
   |    Entrées |               | Sorties   |
  ------------> |  Traitements  | ----------->
   |   Résultat |               |           |
  <------------ |               |           |
   |             ---------------            |
   |                                        |
    ----------------------------------------

II-B. Implémentation du modèle.

Les entrées sont implémentées par de simples appels de fonctions appelées 'points d'entrées'. Vu de l'extérieur, il s'agit d'une interface (API). Chaque interface de fonction est décrite par son nom, ses paramètres (nom et type) et son type de retour.

Les traitements sont le corps des fonctions. Certains traitements sont des actions directes sur les données, et sont retournés immédiatement, d'autres nécessitent de la mémoire permanente pour gérer des configurations, états, compteurs, files etc. Il peut aussi y avoir des fonctions de lecture qui permettent d'accéder à certaines données internes. Enfin, selon des critères bien définis, le traitement peut générer des appels à des fonctions de sorties (événements).

Les fonctions de sorties sont extérieures au composant. Leur appel se fait donc via une variable contenant l'adresse de la fonction. Par contre, la spécification de l'interface des fonctions de sorties appartient à la définition du composant. Il est probable que dans le cadre de l'intégration du composant dans un projet, les fonctions de sorties doivent être développées projet par projet. C'est une opération simple qui nécessite de fournir l'interface spécifiée, et l'appel à la fonction extérieure (qui peut très bien être un point d'entrée d'un autre composant logiciel, voire du même), parfois au prix d'un transcodage de valeur.

La modélisation détaillée d'un composant logiciel et de son environnement est donc la suivante :

Sélectionnez
    --------------------------------------------------------------------
   |                      Application                                   |
   |--------------------------------------------------------------------|
   |         ----------                   ------------------            |
   |        | Appelant |                 |                  |           |
   |         ----------                  |                  v           |
   |            |^                       |               --------       |
   |            ||                       |              | Sortie |      |
   |            ||                       |               --------       |
    ------------||-----------------------|------------------------------
                v|                       |
           --------------------------    |
    ------|         Entrées          |---|------
   |C L |  --------------------------    |      |
   |o o |         |^                     |      |
   |m g |         ||                     |      |
   |p i |         v|                     |      |
   |o c |  ----------------              |      |
   |s i | |                |         ---------  |
   |a e | |  Traitements   | -----> | Sorties | |
   |n l | |                |         ---------  |
   |t   | |                |                    |
   |    |  ----------------                     |
   |    |         |^                            |
    --------------||----------------------------
                  ||
                  v|
              --------------
             | Bibliothèque |
             |   standard   |
              --------------

III. Étude de cas : Un gestionnaire d'alarme à niveaux

III-A. Spécifications

On dispose d'un système qui fait des mesures de grandeurs physiques. On souhaite ajouter un mécanisme qui déclenche une alarme lorsque un certain seuil est dépassé, et qui supprime l'alarme lorsqu'on repasse en dessous d'un autre seuil.

La gamme de mesure acceptable couvre les valeurs de -32767 à 32767. Les seuils sont programmables sur toute la gamme de mesure. Le seuil de retour doit être inférieur au seuil de déclenchement.

III-B. Conception

A partir du modèle de composant logiciel standard, on ajoute les spécificités du projet :

Sélectionnez
       ----------------------------------------------
      |         Composant logiciel 'Alarme'          |
      |----------------------------------------------|
      |                                              |
      |             ---------------                  |
      |    Entrées |  Traitements  | Sorties         |
      |            |               |                 |
      |            |               | Alarme (ON/OFF) |
      |  Réglage   |               | ------------------>
      |  seuils    |               |                 |
  ---------------> |               |                 |
  <--------------- |               |                 |
      |            |               |                 |
      |  Mesure    |               |                 |
  ---------------> |               |                 |
  <--------------- |               |                 |
      |             ---------------                  |
      |                                              |
       ----------------------------------------------

Le traitement peut être décrit par un algorithme textuel :

Sélectionnez
Si la mesure est supérieure ou égale au seuil de déclenchement,
et que l'on est pas en alarme, déclencher l'alarme.

Si l'alarme est déclenchée et que la mesure est inférieure au seuil
de retour, arrêter l'alarme.

Ce que l'on peut traduire en langage algorithmique de la façon suivante :

Sélectionnez
IF NOT in_alarme
   IF mesure >= seuil_declenchement
      in_alarme := TRUE
      sortie_alarme (ON)
   ENDIF
ELSE
   IF mesure < seuil_retour
      in_alarme := FALSE
      sortie_alarme (OFF)
   ENDIF
ENDIF

Les données permanentes sont donc :

  • les données de configuration :
    • le seuil de déclenchement ;
    • le seuil de retour ;
    • l'adresse de la fonction de sortie.
  • les données d'état :
    • l'état courant de l'alarme.

Les fonctions nécessaires à l'exploitation sont :

  • les fonctions de configuration :
    • enregistrement de la fonction de sortie 'alarme' ;
    • réglage du seuil de déclenchement ;
    • réglage du seuil de retour.
  • les fonctions d'évaluation :
    • évaluation de la mesure courante.

III-C. Réalisation en C

III-C-1. Nommage du composant logiciel

Le composant logiciel est nommé 'alarme'. Le préfixe est donc 'alarme_'

III-C-2. Interface

Les données sont rassemblées dans la structure suivante :

Sélectionnez
typedef struct
{
   int seuil_declenchement;
   int seuil_retour;
   unsigned active:1;
}
alarme_s;

Les seuils sont de type int, qui couvre la plage requise par la spécification. Le champ 'active', qui contient l'état courant de l'alarme, peut prendre les valeurs 0 et 1, d'où l'utilisation d'un champ de bits de 1 bit.

Il manque le champ qui contient l'adresse de la fonction de sortie. Il est très pratique d'utiliser un typedef pour spécifier le type de cette fonction, surtout qu'à ce stade de la réalisation, on n'a pas une vision très claire de cette fonction. Le type risque donc d'évoluer, d'où l'intérêt du typedef qui concentre la définition du type en un seul endroit. Dans un premier temps, je propose :

Sélectionnez
/* ---------------------------------------------------------------------
   alarme_out_f
   ---------------------------------------------------------------------
   type de la fonction de sortie.
   ---------------------------------------------------------------------
   I: on : 0 = OFF (alarme inactive) 1 = ON (alarme active)
   O: pas de retour
   --------------------------------------------------------------------- */
typedef void alarme_out_f (int on);

La structure peut donc s'écrire :

Sélectionnez
/* donnees internes */
typedef struct
{
   /* configuration */
   int seuil_declenchement;
   int seuil_retour;
   alarme_out_f *pf_out;

   /* etat */
   unsigned active:1;
}
alarme_s;

Il reste à définir le prototype des fonctions d'exploitations. Il est d'usage d'implémenter un système de gestion d'erreur minimum, qui consiste à retourner 0 quand tout va bien, et 1 en cas de problèmes.

Sélectionnez
/* ---------------------------------------------------------------------
   alarme_install_out()
   ---------------------------------------------------------------------
   Enregistrement de la fonction de sortie 'alarme'
   ---------------------------------------------------------------------
   I : contexte
   I : adresse de la fonction
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_install_out (alarme_s *this, alarme_f *pf);

/* ---------------------------------------------------------------------
   alarme_trigger_level()
   ---------------------------------------------------------------------
   Réglage du seuil de déclenchement
   ---------------------------------------------------------------------
   I : contexte
   I : valeur du seuil
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_trigger_level (alarme_s *this, int level);

/* ---------------------------------------------------------------------
   alarme_return_level()
   ---------------------------------------------------------------------
   Réglage du seuil de retour
   ---------------------------------------------------------------------
   I : contexte
   I : valeur du seuil
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_return_level (alarme_s *this, int level);

/* ---------------------------------------------------------------------
   alarme_eval()
   ---------------------------------------------------------------------
   Évaluation de la mesure. Détection des réactions éventuelles.
   ---------------------------------------------------------------------
   I : contexte
   I : valeur à évaluer
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_eval (alarme_s *this, int value);

III-C-3. Implémentation

Maintenant que l'interface des fonctions est spécifiée, on peut écrire l'implémentation des fonctions publiques (points d'entrée). Il suffit de recopier l'interface et d'écrire le corps des fonctions. Il est d'usage de sécuriser les fonctions en effectuant des tests de validité (valeur, domaine) sur les paramètres. D'autre part, afin d'éviter des erreurs à venir, il est fortement recommandé de définir les paramètres 'non-modifiables' à l'aide du qualificateur const. Ca évite de perdre une valeur qui aurait pu être précieuse.

Sélectionnez
/* ---------------------------------------------------------------------
   alarme_install_out()
   ---------------------------------------------------------------------
   Enregistrement de la fonction de sortie 'alarme'
   ---------------------------------------------------------------------
   I: contexte
   I: adresse de la fonction
   O: retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_install_out (alarme_s *const this, alarme_f *const pf)
{
   /* par defaut, pas d'erreur */
   int err = 0;

   /* pas question de continuer si le contexte est NULL */
   if (this != NULL)
   {
      /* enregistrer l'adresse de la fonction de sortie */
      this->pf = pf;
   }
   else
   {
      err = 1;
   }

   return err;
}

/* ---------------------------------------------------------------------
   alarme_trigger_level()
   ---------------------------------------------------------------------
   Réglage du seuil de déclenchement
   ---------------------------------------------------------------------
   I : contexte
   I : valeur du seuil
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_trigger_level (alarme_s *const this, int const level)
{
   int err = 0;

   if (this != NULL)
   {
      if (level >= ALARME_VALUE_MIN
       && level <= ALARME_VALUE_MAX)
      {
         this->seuil_declenchement = level;
      }
      else
      {
         err = 1;
      }
   }
   else
   {
      err = 1;
   }

   return err;
}

/* ---------------------------------------------------------------------
   alarme_return_level()
   ---------------------------------------------------------------------
   Réglage du seuil de retour
   ---------------------------------------------------------------------
   I : contexte
   I : valeur du seuil
   O : retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_return_level (alarme_s *const this, int const level)
{
   int err = 0;

   if (this != NULL)
   {
      if (level >= ALARME_VALUE_MIN
       && level <= ALARME_VALUE_MAX)
      {
         this->seuil_retour = level;
      }
      else
      {
         err = 1;
      }
   }
   else
   {
      err = 1;
   }

   return err;
}

/* ---------------------------------------------------------------------
   alarme_eval()
   ---------------------------------------------------------------------
   Évaluation de la mesure. Détection des réactions éventuelles.
   Implémentation de l'algorithme :

   IF NOT in_alarme
      IF mesure >= seuil_declenchement
         in_alarme := TRUE
         sortie_alarme (ON)
      ENDIF
   ELSE
      IF mesure < seuil_retour
         in_alarme := FALSE
         sortie_alarme (OFF)
      ENDIF
   ENDIF

   ---------------------------------------------------------------------
   I: contexte
   I: valeur à évaluer
   O: retour : 0 = OK 1 = erreur
   --------------------------------------------------------------------- */
int alarme_eval (alarme_s *const this, int const value)
{
   int err = 0;

   if (this != NULL)
   {
      if (valuel >= ALARME_VALUE_MIN
       && value <= ALARME_VALUE_MAX)
      {
         if (!this->active)
         {
            if (value > this->seuil_declenchement)
            {
               this->active = 1;

               out (this, 1);
            }
         }
         else
         {
            if (value < this->seuil_retour)
            {
               this->active = 0;

               out (this, 0);
            }
         }
      }
      else
      {
         err = 1;
      }
   }
   else
   {
      err = 1;
   }

   return err;
}

Pour terminer l'implémentation, il reste à définir les constantes ALARME_VALUE_MIN et ALARME_VALUE_MAX…

Sélectionnez
#define ALARME_VALUE_MIN -32767
#define ALARME_VALUE_MAX 32767

… et la fonction interne (privée) out().

Sélectionnez
/* ---------------------------------------------------------------------
   out()
   ---------------------------------------------------------------------
   appel de la fonction de sortie enregistrée (si disponible)
   ---------------------------------------------------------------------
   I: contexte
   I: etat de l'alarme
   O: pas de retour
   --------------------------------------------------------------------- */
static void out (alarme_s *this, int on)
{
   if (this->pf_out != NULL)
   {
       this->pf_out (on);
   }

   /* si le pointeur est NULL, on peut appeler un 'stub()'
    * qui simule la sortie (utile en debug)
    */

}

L'ensemble du composant logiciel 'alarme' est stocké ici (Ver 1.0) : alarme.h alarme.c avec le fichier de test rudimentaire (rien à voir avec un véritable test unitaire) test.c

Nous disposons d'une sorte de 'maquette' qui permet de faire quelques tests, de vérifier l'algorithme etc.

Tant qu'il n'a pas passé une séance de validation unitaire intense, ce n'est certainement pas un produit fini.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Les types abstraits de données (ADT)

  • I. Introduction
  • II. Analyse de l'objet standard FILE
  • III. Définition du type abstrait de donnée
  • IV. Implémentation du type abstrait de donnée en C
    • IV-A. Constructeur
    • IV-B. Destructeur
    • IV-C. Fichier d'interface
    • IV-D. Utilisation
  • V. Exemple réel d'ADT
    • V-A. Spécification
    • V-B. Conception
      • V-B-1. Nommage
      • V-B-2. Fonctions
      • V-B-3. Interfaces détaillées
    • V-C. Réalisation en langage C
      • V-C-1. Fichier d'interface
        • V-C-1-a. Note de codage
      • V-C-2. Fichier d'implémentation
      • V-C-3. Remarque
    • V-D. Tests unitaires

Cet article présente la possibilité de programmer en C en utilisant une approche plus 'objet' que 'fonction'.

I. Introduction

Le langage C est orienté 'fonction'. Cela signifie que la fonction est l'entité de base de la construction d'un programme écrit en C. L'idée directrice est qu'un programme n'est qu'une suite d'instructions organisées en fonctions. Cependant, force est de constater qu'un travail sans données n'a pas grand intérêt.

D'autres langages sont orientés 'objet'. Cela signifie que la construction du code est organisée autour des données. Les fonctions (méthodes) n'étant que des outils au service du traitement des données.

Le langage C ne dispose pas naturellement des 'classes' qui permettent de construire un objet auquel sont directement rattachées des 'méthodes'. Cependant, il est possible de programmer en C en utilisant une approche plus 'objet' que ne le laissait supposer le langage au départ. Un bon exemple est donné par l'implémentation de la gestion des flux en C standard, qui est organisée autour de l'objet de type FILE.

II. Analyse de l'objet standard FILE

Le langage C définit FILE comme un type dit 'opaque'. C'est à dire que le comportement est décrit, mais pas les détails internes de l'objet. Un certain nombre de fonctions associées définissent le comportement de l'objet. Il existe une paire de fonctions permettant de créer et détruire l'objet :

Sélectionnez
/* C90 */
  FILE *fopen (char const *filename, char const *mode);
  int fclose (FILE *stream);

on a aussi un certain nombre de fonctions permettant la lecture et l'écriture par byte, par bloc, par ligne de texte etc.

Toutes ces fonctions ont en commun un paramètre de type FILE *. Ce paramètre contient l'adresse de l'objet qui a été créé lors de l'instanciation de celui-ci par fopen(). C'est en quelque sorte la 'clé' qui permet d'accéder à la fonction avec le bon contexte. Le principal intérêt de la programmation par contexte est que le code peut être unique et les instanciations infinies, ce qui va dans le sens de la capitalisation du code. De nombreux exemples sont donnés dans ma bibliothèque de fonctions qui est le plus souvent organisée en modules regroupés autour de la définition d'un certain objet.

III. Définition du type abstrait de donnée

Les données n'étant pas accessibles directement, mais par l'intermédiaire d'un pointeur passé à des fonctions, la seule information accessible à l'utilisateur est l'adresse du bloc de mémoire qui contient les données. Étant donné que la structure interne des données est inconnue de l'extérieur, on parle de données 'opaques' ou encore de 'type abstrait de données' (abstract data type ou ADT).

IV. Implémentation du type abstrait de donnée en C

L'implémentation de l'objet se fait par une structure de données.

Sélectionnez
struct objet
{
   int data;
};

Le principe de l'abstraction repose sur la compilation séparée. En effet, on va pouvoir fournir d'une part l'interface publique de l'objet sous la forme d'une définition incomplète de la structure

Sélectionnez
struct objet;

et d'autre part, dans le module qui va implémenter les fonctions (et pour commencer, les 'créateurs / destructeurs'), la définition 'privée' de la structure telle qu'elle a été décrite ci-dessus.

IV-A. Constructeur

La définition externe de la structure de données étant incomplète, il n'est évidemment pas possible de définir une instanciation statique de l'objet. Le rôle du constructeur est donc d'allouer dynamiquement une instance de l'objet et éventuellement d'initialiser ses membres à 0.

La méthode la plus évidente est d'utiliser malloc()/free() pour créer / détruire l'objet, mais il est parfaitement possible, dans les cas spécifiques (application embarquée critique, par exemple), d'utiliser un allocateur 'local' utilisant une zone de données 'pré-allouée' statique ou dynamique selon les besoins et possibilités du système).

Sélectionnez
/* fichier d'implémentation */

#include <stdlib.h>

struct objet
{
   int data;
};

/* constructeur */
struct objet * objet_create (void)
{
   struct objet * self = malloc (sizeof *self);

   if (self != NULL)
   {
      /* initialisation a 0 */
      static const struct objet z = {0};
      *self = z;

      /* ajouter ici les éventuels compléments...
       * - Autres initialisations
       * - Création d'objets imbriques etc.
       */
   }

   return self;
}

Il est toujours possible de prévoir un mécanisme de paramétrage qui permet d'adapter l'objet à ses besoins.

IV-B. Destructeur

Il s'agit simplement de libérer le bloc alloué en utilisant la méthode réciproque à celle qui a servi à faire l'allocation (malloc() -> free()). Si il existe des objets imbriqués, ceux-ci doivent évidemment être détruits avant la libération de l'objet courant.

Sélectionnez
/* fichier d'implémentation */

/* voir details ci-dessus... */

/* destructeur */
void objet_delete (struct objet * self)
{
   if (self != NULL)
   {
      /* ajouter ici les éventuels compléments...
       * - Destruction d'objets imbriques etc.
       */
   }

   free (self);
}

IV-C. Fichier d'interface

Le fichier d'interface (header) comporte la définition incomplète de la structure et les prototypes des fonctions. Ce fichier est inclus dans le fichier d'implémentation de l'objet et dans tous les fichiers utilisateurs. Comme tout fichier d'en-tête, il est protégé contre les inclusions multiples.

Sélectionnez
/* fichier d'interface */

/* structure  */
struct objet;

/* fonctions  */
struct objet * objet_create (void);
void objet_delete (struct objet * self);

IV-D. Utilisation

Voici un exemple de création / destruction d'un TAD. Comme tout objet dynamique, sa création peut échouer. Il convient donc de tester la valeur de l'adresse retournée avant de l'utiliser. Une fois l'utilisation terminée, l'objet est détruit, et le pointeur est forcé à NULL, ce qui interdit la réutilisation d'un objet détruit. Penser à mettre à NULL les éventuels alias sur le bloc (autres pointeurs sur le même bloc).

Sélectionnez
/* fichier utilisateur */

#include "objet.h"

{
   struct objet * p_obj = objet_create();

   if (p_obj != NULL)
   {
      /* utilisation... */

      objet_delete (p_obj), p_obj = NULL;
   }
}

V. Exemple réel d'ADT

V-A. Spécification

Soit à réaliser un ADT permettant de créer ou utiliser une matrice (tableau à deux dimensions) de valeurs de type double.

Les dimensions minimales sont de 1 x 1. Les dimensions maximales ne sont pas imposées. Elles sont en fait limitées par l'architecture et l'état du système à un moment donné. L'ADT fournit les fonctions de construction/destruction habituelles, ainsi que des fonctions d'écriture/lecture à des coordonnées du tableau. Tout est mis en œuvre pour assurer la sécurité et la fiabilité de l'ADT.

V-B. Conception

V-B-1. Nommage

Le choix du nom de l'ADT est important. En effet, il doit à la fois être unique (au projet et/ou à la bibliothèque), sans ambiguïté et court (maximum 4 à 5 lettres) pour ne pas surcharger l'écriture. Il est utilisé comme préfixe à tous les identificateurs publics en relation avec l'ADT, notamment le type et les fonctions.

Le nom retenu est 'matd' (MATrice Double).

V-B-2. Fonctions

Les fonctions sont :

  • matd_create() ;
  • matd_delete() ;
  • matd_put() ;
  • matd_get().

V-B-3. Interfaces détaillées

  • matd_create(nb_colonnes, nb_lignes) ;
  • matd_delete() ;
  • matd_put(colonne, ligne, valeur) ;
  • matd_get(colonne, ligne, valeur).

V-C. Réalisation en langage C

La réalisation se compose d'un fichier d'interface "matd.h" et d'un fichier d'implémentation "matd.c".

V-C-1. Fichier d'interface

J'ai pour habitude d'utiliser un alias sur le type structure (typedef), ce qui a pour avantage d'alléger l'écriture. Ce n'est pas indispensable, et il est tout à fait possible d'utiliser directement 'struct xxx' si on préfère.

J'ai choisi le type size_t pour les dimensions et les coordonnées, car c'est le type le plus adapté à ce genre de valeur en C. La définition de size_t se trouve dans <stddef.h>

Le paramètre 'valeur' de matd_get() est de type pointeur sur double, car il s'agit de récupérer une valeur. On fournit donc à la fonction l'adresse de la variable de destination.

Les fonctions (autres que constructeur et destructeur) retournent un compte rendu d'exécution : 0 = OK 1 = erreur.

Sélectionnez
#ifndef H_MATD
#define H_MATD

/* matd.h */

#include <stddef.h>

/* types */
typedef struct matd matd_s;

/* fonctions */
matd_s *matd_create (size_t nb_colonnes, size_t nb_lignes);
void matd_delete (matd_s * self);

int matd_put (matd_s * self, size_t colonne, size_t ligne, double valeur);
int matd_get (matd_s * self, size_t colonne, size_t ligne, double *p_valeur);

#endif /* guard *

V-C-1-a. Note de codage

Il est possible d'aller plus loin dans l'abstraction en définissant un alias englobant la notion de pointeur. Je n'encourage pas cette pratique, car elle peut provoquer de la confusion, mais il faut savoir qu'elle existe. Appliquée à l'exemple en cours, le fichier d'interface se présentait ainsi (non testé) :

Sélectionnez
#ifndef H_MATD
#define H_MATD

/* matd.h */

#include <stddef.h>

/* types */
typedef struct matd * matd_p;

/* fonctions */
matd_p matd_create (size_t nb_colonnes, size_t nb_lignes);
void matd_delete (matd_p self);

int matd_put (matd_p self, size_t colonne, size_t ligne, double valeur);
int matd_get (matd_p self, size_t colonne, size_t ligne, double *p_valeur);

#endif /* guard */

V-C-2. Fichier d'implémentation

Je choisis une implémentation linéaire. Une implémentation par tableau de pointeurs sur tableaux dynamiques de doubles est aussi possible.

Allocation d'un tableau de colonnes x lignes doubles.

Sélectionnez
/* matd.c */

#include "matd.h"
#include <stdlib.h>

/* structures */
struct matd
{
   size_t nb_colonnes;
   size_t nb_lignes;
   double *tab;
};

/* fonctions privees */

static double *get_addr (matd_s * self, size_t colonne, size_t ligne)
{
   double *p = NULL;

   if (ligne < self->nb_lignes && colonne < self->nb_colonnes)
   {
      p = self->tab + (ligne * self->nb_colonnes) + colonne;
   }

   return p;
}

/* fonctions publiques */

matd_s *matd_create (size_t nb_colonnes, size_t nb_lignes)
{
   matd_s *self = NULL;

   if (nb_lignes > 0 && nb_colonnes > 0)
   {
      self = malloc (sizeof *self);

      if (self != NULL)
      {
         static const matd_s z =
         {0};
         *self = z;
         {
            size_t const size = nb_lignes * nb_colonnes;
            double *tab = malloc (sizeof *tab * size);

            if (tab != NULL)
            {
               size_t i;
               for (i = 0; i < size; i++)
               {
                  tab[i] = 0;
               }

               self->nb_lignes = nb_lignes;
               self->nb_colonnes = nb_colonnes;
               self->tab = tab;

            }
            else
            {
               free (self), self = NULL;
            }
         }
      }
   }
   return self;
}

void matd_delete (matd_s * self)
{
   if (self != NULL)
   {
      free (self->tab), self->tab = NULL;
   }
   free (self);
}

int matd_put (matd_s * self, size_t colonne, size_t ligne, double valeur)
{
   int err = 1;

   if (self != NULL)
   {
      double *p = get_addr (self, colonne, ligne);

      if (p != NULL)
      {
         *p = valeur;
         err = 0;
      }
   }
   return err;
}

int matd_get (matd_s * self, size_t colonne, size_t ligne, double *p_valeur)
{
   int err = 1;

   if (self != NULL)
   {
      double const *p = get_addr (self, colonne, ligne);

      if (p != NULL)
      {
         if (p_valeur != NULL)
         {
            *p_valeur = *p;
            err = 0;
         }
      }
   }
   return err;
}

V-C-3. Remarque

L'initialisation de la structure est faite par la recopie d'une structure d'instance unique (définie 'static const') initialisée à 0. Pour faciliter la maintenance, j'ai écrit 'z = {0}'.

Certains compilateurs un peu sensibles peuvent signaler un warning à propos de valeurs d'initialisation manquantes. On peut ignorer ces warnings ou écrire l'initialisation complète : z = {0, 0, NULL}

V-D. Tests unitaires

Voici un test élémentaire qui permet de vérifier le bon fonctionnement de l'ADT. (syntaxe, compilation, comportement basique). Il manque des tests aux limites pour vérifier la stabilité de l'ADT dans les cas extrêmes.

Sélectionnez
/* test.c */

#include "matd.h"
#include <stdio.h>
#include <stdlib.h>

#define TERR(e)                                             \
do                                                          \
{                                                           \
   if (e)                                                   \
   {                                                        \
      printf ("Error %d at %s:%d\n",e ,__FILE__, __LINE__); \
      exit (EXIT_FAILURE);                                  \
   }                                                        \
}                                                           \
while (0)

int main (void)
{
   enum
   {
      NB_COL = 2,
      NB_LIN = 3,
      dummy
   };

   matd_s *m = matd_create (NB_COL, NB_LIN);

   if (m != NULL)
   {
      int err;

      err = matd_put (m, 0, 0, 0.0);
      TERR(err);
      err = matd_put (m, 0, 1, 0.1);
      TERR(err);
      err = matd_put (m, 0, 2, 0.2);
      TERR(err);
      err = matd_put (m, 1, 0, 1.0);
      TERR(err);
      err = matd_put (m, 1, 1, 1.1);
      TERR(err);
      err = matd_put (m, 1, 2, 1.2);
      TERR(err);

      {
         size_t i;

         for (i = 0; i < NB_COL; i++)
         {
            size_t j;

            for (j = 0; j < NB_LIN; j++)
            {
               double d;

               err = matd_get (m, i, j, &d);
               TERR(err);

               printf ("%.2f ", d);
            }
            printf ("\n");
         }
      }
      matd_delete (m), m = NULL;
   }

   return 0;
}

Résultats

Sélectionnez
0.00 0.10 0.20
1.00 1.10 1.20

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Notes sur les réseaux

  • I. Dialogue Terminal Modem
  • II. Réalisation d'un projet réseau
    • II-A. Sockets
      • II-A-1. Introduction
      • II-A-2. Protocoles
      • II-A-3. Fonctions
      • II-A-4. Serveur
      • II-A-5. Client
    • II-B. Réalisation d'un Client / Serveur UDP/IP
      • II-B-1. Serveur UDP/IP
      • II-B-2. Client UDP/IP
    • II-C. Réalisation d'un Client / Serveur TCP/IP
      • II-C-1. Serveur TCP/IP
      • II-C-2. Client TCP/IP
      • II-C-3. Serveur multiclient en TCP/IP
  • III. Réalisation pas à pas d'un serveur TCP/IP
    • III-A. Introduction
      • III-A-1. Avertissement
      • III-A-2. Environnement de développement
      • III-A-3. Environnement de test
      • III-A-4. Traitement des erreurs
    • III-B. 01 - Initialisation, terminaison
    • III-C. 02 - Création, suppression de sockets
    • III-D. 03 - Mini serveur : mise en écoute du port 23
    • III-E. 04 - Mini serveur : connexion du client/ Déconnexion du client
    • III-F. 05 - Réception d'un caractère puis fermeture
    • III-G. 06 - Mini serveur : réception d'un bloc de données puis fermeture sur ESC
      • III-G-1. Pseudo-code
      • III-G-2. Code
    • III-H. 07 - Mini serveur : réception d'un bloc de données puis deconnexion sur ESC
      • III-H-1. Pseudo-code
      • III-H-2. Code
    • III-I. 08 - Serveur-multi-clients : réception d'un bloc de données puis deconnexion sur ESC
      • III-I-1. Pseudo-code
      • III-I-2. Code
    • III-J. Conclusion
  • IV. Transmettre du texte par sockets
    • IV-A. Emission de petits blocs (cas simple)
    • IV-B. Réception de petits blocs
    • IV-C. Émission de gros blocs (cas général)
    • IV-D. Réception de gros blocs
    • IV-E. Étude de cas : transmettre un fichier texte
      • IV-E-1. Spécifications
      • IV-E-2. Conception
      • IV-E-3. Protocole
      • IV-E-4. Format des messages
      • IV-E-5. Commandes
      • IV-E-6. Réponses
      • IV-E-7. Exemple de dialogue
  • V. Transmettre des données binaires par sockets
    • V-A. En-tête
    • V-B. Protocole
    • V-C. Mise en œuvre
      • V-C-1. Transmission de l'entête
      • V-C-2. Réception de l'entête
      • V-C-3. Transmission et réception de l'acquittement
      • V-C-4. Transmission des données
      • V-C-5. Réception des données
  • VI. Du bon usage de select()
    • VI-A. Introduction
    • VI-B. Programmation
    • VI-C. Exemples d'utilisation
      • VI-C-1. Suspension temporisée
      • VI-C-2. Surveillance d'un flux en réception
      • VI-C-3. Surveillance de deux flux en réception
      • VI-C-4. Conclusion
  • VII. Recupérer l'adresse IP du serveur
  • VIII. Ébauche d'une bibliothèque sockets portable (psock)
    • VIII-A. Interfaces communes
    • VIII-B. Sockets UNix (sun)
    • VIII-C. Sockets IP (Internet Protocol)
  • IX. Ressources

Notes brutes concernant les réseaux

I. Dialogue Terminal Modem

Sélectionnez
        Terminal               Modem
            |                    |
            |    commande        |
            |------------------->|
            |    réponse         | Traitement
            |<------------------ |
Traitement  |                    |

II. Réalisation d'un projet réseau

La réalisation d'un projet réseau est basée sur l'utilisation des sockets (BSD, Windows). Il y a quelques nuances selon le système utilisé, mais dans l'ensemble, les fonctions et comportements sont identiques. Le but de cet article est de montrer la réalisation de clients / serveurs en UDP/IP et TCP/IP.

II-A. Sockets

II-A-1. Introduction

Les sockets sont des flux de données (octets) très similaires aux flux d'entrée / sortie standards ou aux fichiers, mais qui permettent de réaliser des connexions de données bidirectionnelles entre des machines (locales ou distantes) via un réseau de données (boucle, réseau local, Internet, X.25 etc.). Ils sont mis en oeuvre via une série de fonctions regroupées sous le nom de API sockets ou sockets BSD ou sockets.

II-A-2. Protocoles

Les connexions de données sont gérées par différent protocoles (niv 3 et 4) et différentes liaisons (niv. 2) et interfaces physiques (niv. 1). Les sockets ignorent les interfaces (gérées par les drivers systèmes), mais connaissent les protocoles de niveau 3 et 4 (IP, UNIX / TCP, UDP, etc.). Ensuite, ils savent travailler en mode non connecté (datagrammes, simple, pas de vérification, ordre indéterminé) ou connecté (paquets, plus complexe, données vérifiées, intégrité des données, ordre garanti).

Par exemple, le protocole de niveau 3 IP (Internet Protocol) sait travailler en mode de niveau 4 connecté (TCP) ou non connecté (UDP).

II-A-3. Fonctions

Les sockets sont manipulés par des fonctions générales :

  • socket() ;
  • close() ou closesocket() ;
  • send(), recv(), (mode connecté) ;
  • sendto(), recvfrom() (mode non connecté) ;
  • et des fonctions spécialisées qui dépendent si l'application est un serveur ou un client et si on utilise un mode connecté ou non.

II-A-4. Serveur

  • bind() (TCP) ;
  • listen() ;
  • accept() (mode connecté).

II-A-5. Client

  • connect() (mode connecté)

Rappel : La documentation complète des fonctions se trouve dans les 'pages man', comme, par exemple, celles-ci.

II-B. Réalisation d'un Client / Serveur UDP/IP

Sélectionnez
          Client              Serveur
            |                    |
            |   indication       |
            |------------------->|
            |                    |
            |    commande        |
            |------------------->|
            |   [réponse]        | Traitement
            |<- - - - - - - - -  |
[Traitement]|                    |

Le mode UDP (Datagram sockets) est rustique et ne nécessite pas de connexion. Il agit par échange de blocs de données appelés 'Datagrammes'. Un serveur ne peut pas émettre spontanément de données vers un client, car il ne le connaît pas. Il ne peut que répondre à une commande en utilisant les données d'adressage reçues avec la commande à traiter.

II-B-1. Serveur UDP/IP

Les opérations à réaliser sont :

  • initialisation :
    • ouverture d'un socket en mode datagramme (UDP/IP): socket(AF_INET, SOCK_DGRAM, 0) ;
    • configurer l'adresse et le port: bind() ;
  • dans une boucle :
    • tester la réception : select() [Facultatif] ;
    • lire le bloc reçu : recvfrom() ;
    • traitement des données… ;
    • émission du bloc : sendto().
  • fin :
    • fermeture du socket : closesocket().

II-B-2. Client UDP/IP

Les opérations à réaliser sont :

  • initialisation :
    • ouverture d'un socket en mode datagramme (UDP/IP): socket(AF_INET, SOCK_DGRAM, 0).
  • selon la demande :
    • fabrication des données à émettre… ;
    • émission du bloc : sendto() ;
    • tester la réception : select() [Facultatif] ;
    • lire le bloc reçu : recvfrom() ;
    • traitement des données reçues…
  • fin :
    • fermeture du socket : closesocket().

Exemple de client / serveur UDP :

  • serveur ;
  • client.

II-C. Réalisation d'un Client / Serveur TCP/IP

Sélectionnez
         Client              Serveur
            |                    |
            | modem. de connexion  |
            |------------------->|
            |   acceptation      |
            |<- - - - - - - - -  |
            |                    |
[Traitement]|   indication       |
            |<- - - - - - - - -  |
            |                    |
            |   indication       |
            |------------------->|
            |                    |
            |    commande        |
            |------------------->|
            |   [réponse]        | Traitement
            |<- - - - - - - - -  |
[Traitement]|                    |
            |                    |
            |    déconnexion     |
            |<- - - - - - - - - >|

Le mode TCP (Stream sockets) est solide. Il garanti la transmission des données. Il nécessite l'établissement d'une connexion. Ensuite, les blocs de données peuvent être échangés.

II-C-1. Serveur TCP/IP

Les opérations à réaliser sont :

  • initialisation :
    • ouverture d'un socket en mode flux (TCP/IP): socket(AF_INET, SOCK_STREAM, 0) ;
    • configurer l'adresse et le port: bind() ;
    • configurer le nombre d'écoutes: listen().
  • dans une boucle :
    • accepter une connexion: accept() ;
    • tester la réception : select() [Facultatif] ;
    • recevoir des données: recv() ;
    • émettre des données: send() ;
    • déconnecter : shutdown().
  • fin :
    • fermeture du socket : closesocket().

II-C-2. Client TCP/IP

Les opérations à réaliser sont :

  • initialisation :
    • ouverture d'un socket en mode flux (TCP/IP): socket(AF_INET, SOCK_STREAM, 0) ;
    • connexion: connect().
  • selon la demande :
    • émettre des données: send() ;
    • tester la réception : select() [Facultatif] ;
    • recevoir des données: recv().
  • fin :
    • déconnecter : shutdown() ;
    • fermeture du socket : closesocket().

client minimum (emission)

II-C-3. Serveur multiclient en TCP/IP

Le principe est de réduire la boucle principale à la surveillance des connexions entrantes (accept()) et de créer un thread comprenant la boucle de traitement avec les données de la connexion client.

thread 'serveur' : dans une boucle :

  • attendre une connexion: accept() ;
  • créer un thread avec le socket client.

thread 'client' : dans une boucle :

  • attendre des données et les lire: recv() ;
  • émettre des données en réponse: send().

Après la boucle (deconnexion, timeout...) :

  • déconnecter : shutdown().

III. Réalisation pas à pas d'un serveur TCP/IP

III-A. Introduction

Il s'agit d'une application de mise en œuvre écrite en langage C. Le détail des fonctions (paramètres, comportement) doit être consulté dans les documents de références habituels (msdn, man etc.).

III-A-1. Avertissement

Cette réalisation demande une bonne connaissance du langage C. Cependant, je m'efforce de n'utiliser que des concepts simples du langage. Si je juge qu'un point est difficile, je fais une remarque ou je renvoie à une explication détaillée. En cas de difficulté avérée, poser une question dans la rubrique réseau du forum.

III-A-2. Environnement de développement

Le code est écrit et validé sous Windows avec Dev-C++/Code::Blocks et la bibliothèque -lws2_32. Il compile sous Linux, mais n'est pas validé pour le moment. (Si quelqu'un veut le faire, il est le bienvenu)

III-A-3. Environnement de test

Il suffit d'une machine supportant le protocole TCP/IP, et munie d'une adresse IP (sinon, utiliser 127.0.0.1). Si on dispose de 2 machines, on peut espionner les échanges de trames avec EtherReal.

III-A-4. Traitement des erreurs

J'ai développé la fonction portable 'psock_perror()' basée sur perror() et WSAGetLastError() selon le système. Un exemple minimum est donné dans le code source suivant nommé "02.c".

III-B. 01 - Initialisation, terminaison

Sous Windows, il est nécessaire d'indiquer au système que le processus courant veut utiliser les sockets. Ce n'est pas nécessaire sous Linux. Sous Windows, winsock2 est compatible avec les sockets BSD. Il est donc indispensable de vérifier qu'on a bien la bonne version de sockets sur la machine cible. De plus, Windows exige de signaler la fin d'utilisation des sockets par le processus.

Voici notre premier programme 'réseau'. Pour le moment, il ne fait rien sous unixoïde, mais sous Windows, il vérifie la version des sockets.

Sélectionnez
/* 01.c */

#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h> /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */
/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */
/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;

#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
   }
#else
   int err = 0;
   {
#endif
      if (!err)
         /* to be continued ... */

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }

   return ret;
}

Sortie attendue :

Sélectionnez
WIN: winsock2: OK

III-C. 02 - Création, suppression de sockets

Ce programme ne fait rien de visible. Il se contente de créer un socket en mode 'Internet' (IP), puis de le fermer proprement.

Nouvelles fonctions : socket(), closesocket()

Traitement portable des erreurs

Sélectionnez
/* 02.c */

#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h> /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */

#define NELEM(a) (sizeof(a)/sizeof*(a))

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static void psock_perror (char const *cmt)
{
#if defined (WIN32) || defined (_WIN32)

   fprintf (stderr, "%s: ", cmt);
   {
      DWORD err = WSAGetLastError ();
      wchar_t msg[1024];
      FormatMessageW (FORMAT_MESSAGE_FROM_SYSTEM, NULL, err, 0,
                      msg, NELEM (msg), NULL);
      fwprintf (stderr, L"%s\n", msg);
   }

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

   perror (cmt);

#else
#error not defined for this platform
#endif

}

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      {
         int sock_err;

         /* to be continued ... */

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            psock_perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      psock_perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }
   return ret;
}

Exemple de sortie attendue :

Sélectionnez
WIN: winsock2: OK
socket 44 is now opened in TCP/IP mode
closing socket 44...
the socket is now closed

III-D. 03 - Mini serveur : mise en écoute du port 23

Le port 23 est le port Telnet par défaut. La encore, rien de spectaculaire, si ce n'est que si la machine dispose d'un pare-feu (comme Zone Alarm, par exemple), celui-ci signale que le programme veut agir en tant que serveur sur le port SMTP. C'est la conséquence de l'exécution de la fonction 'listen()'. Si le pare-feu demande confirmation, l'exécution du programme est suspendue jusqu'à ce que la confirmation soit validée. Ensuite, comme il n'y a pour le moment rien d'autre à faire, le programme se termine.

Attention, sous unixoïde, le port 23 n'est accessible que si on est en root.

Nouvelles fonctions : bind(), listen(), htons(), htonl().

Sélectionnez
/* 03s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h> /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */

#define TELNET 23

/* we want to listen to the TELNET port */
#define PORT TELNET

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin =
         {0};

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) &sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               /* to be continued ... */
            }
            else
            {
               err = 1;
            }
         }
         else
         {
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }
   return ret;
}

Exemple de sortie attendue :

Sélectionnez
WIN: winsock2: OK
socket 44 is now opened in TCP/IP mode
listening on port 23...
closing socket 44...
the socket is now closed

III-E. 04 - Mini serveur : connexion du client/ Déconnexion du client

Le serveur va maintenant se bloquer en attente de connexion d'un client. On peut alors lancer un client Telnet sur cette machine (ou une autre sur le même réseau) avec le port 23. On constate alors, sur le client, que la connexion est immédiatement perdue. Normal, car le serveur a renvoyé une déconnexion immédiate du socket client.

Nouvelles fonctions : accept(), shutdown(), inet_ntoa().

Sélectionnez
/* 04s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h> /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */

/* we want to listen to the TELNET port */
#define TELNET 23

#define PORT TELNET

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin =
         {0};

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) &sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               /* wait for a client connection */
               printf ("waiting for a client connection on port %d...\n", PORT);
               {
                  SOCKADDR_IN csin =
                  {0};
                  int recsize = (int) sizeof csin;
                  SOCKET csock = accept (sock, (SOCKADDR *) & csin, &recsize);

                  if (csock != INVALID_SOCKET)
                  {
                     printf ("client connected with socket %d from %s:%d\n"
                             ,csock
                             ,inet_ntoa (csin.sin_addr)
                             ,htons (csin.sin_port));

                     /* to be continued ... */

                     shutdown (csock, 2);
                     printf ("closing client socket %d...\n", csock);
                     closesocket (csock), csock = INVALID_SOCKET;

                  }
                  else
                  {
                     perror ("socket.accept");
                     err = 1;
                  }
               }
            }
            else
            {
               perror ("socket.listen");
               err = 1;
            }
         }
         else
         {
            perror ("socket.bind");
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }
   return ret;
}

Exemple de sortie attendue :

Sélectionnez
WIN: winsock2: OK
socket 44 is now opened in TCP/IP mode
listening on port 23...
waiting for a client connection on port 23...
client connected with socket 48 from 192.168.0.17:1290
closing client socket 48...
closing socket 44...
the socket is now closed

III-F. 05 - Réception d'un caractère puis fermeture

Une fois que le client Telnet est lance, le serveur se bloque sur l'attente d'un bloc de donné. Le client telnet envoyant en temps réel toutes la frappes du clavier, un seul caractère suffit à débloquer le serveur qui envoi alors une commande de déconnexion.

Nouvelles fonctions : recv().

Sélectionnez
/* 05s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h> /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */

/* we want to listen to the TELNET port */
#define TELNET 23

#define PORT TELNET

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin =
         {0};

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) &sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               /* wait for a client connection */
               printf ("waiting for a client connection on port %d...\n", PORT);

               {
                  SOCKADDR_IN csin =
                  {0};
                  int recsize = (int) sizeof csin;
                  SOCKET csock = accept (sock, (SOCKADDR *) & csin, &recsize);

                  if (csock != INVALID_SOCKET)
                  {
                     printf ("client connected with socket %d from %s:%d\n"
                             ,csock
                             ,inet_ntoa (csin.sin_addr)
                             ,htons (csin.sin_port));

                     {
                        /* wait for the receive of a data block (string expected, hence + 1) */
                        unsigned char data[128 + 1];
                        sock_err = recv (csock, data, (int) sizeof data - 1, 0);

                        if (sock_err != SOCKET_ERROR)
                        {
                           size_t nb_rec = (size_t) sock_err;
                           /* convert to string */
                           data[nb_rec] = 0;
                           printf ("%u byte%s received:\n%s\n"
                                   ,(unsigned) nb_rec
                                   ,nb_rec > 1 ? "s" : ""
                                   ,data);
                           fflush (stdout);

                           /* to be continued ... */
                        }
                        else
                        {
                           perror ("socket.recv");
                           err = 1;
                        }
                     }
                     shutdown (csock, 2);
                     printf ("closing client socket %d...\n", csock);
                     closesocket (csock), csock = INVALID_SOCKET;

                  }
                  else
                  {
                     perror ("socket.accept");
                     err = 1;
                  }
               }
            }
            else
            {
               perror ("socket.listen");
               err = 1;
            }
         }
         else
         {
            perror ("socket.bind");
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }
   return ret;
}

Exemple de sortie attendue, l'utilisateur tape 'a' sur la console du client :

Sélectionnez
WIN: winsock2: OK
socket 44 is now opened in TCP/IP mode
listening on port 23...
waiting for a client connection on port 23...
client connected with socket 48 from 192.168.0.17:1299
1 byte received:
a
closing client socket 48...
closing socket 44...
the socket is now closed

III-G. 06 - Mini serveur : réception d'un bloc de données puis fermeture sur ESC

L'ajout d'une boucle sur recv() permet de recevoir plusieurs caractères. Si on reçoit ESC (27), la boucle est rompue et une demande de déconnexion est émise vers le client. Le serveur s'arrête.

III-G-1. Pseudo-code

Sélectionnez
BEGIN
   sock := socket()
   bind(sock, ALL_IP, 23)
   listen(sock, 5)
   csock := accept(sock, csin)
   REPEAT
      recv(csock, data)
      send(csock, "OK")
   UNTIL data[0] = ESC
   closesocket(csock)
   closesocket(sock)
END

Ce fonctionnement est simple, mais il n'accepte qu'une seule connexion. Dès qu'elle se termine, le serveur s'arrête. Les fonctions accept() et recv() sont bloquantes.

III-G-2. Code

Sélectionnez
/* 06s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>             /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */

#define TELNET 23

/* we want to listen to the TELNET port */
#define PORT TELNET

#define ESC 27

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin =
         {0};

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) &sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               /* wait for a client connection */
               printf ("waiting for a client connection on port %d...\n", PORT);

               {
                  SOCKADDR_IN csin =
                  {0};
                  int recsize = (int) sizeof csin;
                  SOCKET csock = accept (sock, (SOCKADDR *) &csin, &recsize);

                  if (csock != INVALID_SOCKET)
                  {
                     printf ("client connected with socket %d from %s:%d\n"
                             ,csock
                             ,inet_ntoa (csin.sin_addr)
                             ,htons (csin.sin_port));
                     {
                        int end = 0;
                        do
                        {
                           /* wait for the receive of a data block */
                           unsigned char data[128];
                           sock_err = recv (csock, data, (sizeof data - 1), 0);

                           if (sock_err != SOCKET_ERROR)
                           {
                              size_t nb_rec = sock_err;
                              if (nb_rec > 0)
                              {
                                 /* convert to string */
                                 data[nb_rec] = 0;
                                 printf ("%u byte%s received:\n'%s'\n"
                                         ,(unsigned) nb_rec
                                         ,nb_rec > 1 ? "s" : ""
                                         ,data);
                                 fflush (stdout);

                                 if (data[0] == ESC)
                                 {
                                    end = 1;
                                 }
                                 else
                                 {
                                    /* send some YES-TO-ALL answer */
                                    char const response[] = "OK\n";
                                    send (csock, response, strlen (response), 0);
                                 }
                              }
                              else
                              {
                                 puts("client is disconnected");
                                 end = 1;
                              }
                           }
                           else
                           {
                              perror ("socket.recv");
                              err = 1;
                              end = 1;
                           }
                        }
                        while (!end);
                     }
                     shutdown (csock, 2);
                     printf ("closing client socket %d...\n", csock);
                     closesocket (csock), csock = INVALID_SOCKET;

                  }
                  else
                  {
                     perror ("socket.accept");
                     err = 1;
                  }
               }
            }
            else
            {
               perror ("socket.listen");
               err = 1;
            }
         }
         else
         {
            perror ("socket.bind");
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }

   return ret;
}

III-H. 07 - Mini serveur : réception d'un bloc de données puis deconnexion sur ESC

L'ajout d'une boucle sur accept() permet de recevoir plusieurs demandes de connexions à la suite. Le serveur ne s'arrête jamais.

III-H-1. Pseudo-code

Sélectionnez
BEGIN
   sock := socket()
   bind(sock, ALL_IP, 23)
   listen(sock, 5)
   DO
      csock := accept(sock, csin)
      closesocket(csock)
   FOREVER
   closesocket(sock)
END

Afin de simplifier l'organisation du code, les boucles sont implémentées dans les fonctions clients() et client().

Sélectionnez
FUNCTION client(csock:SOCKET)
BEGIN
   REPEAT
      recv(csock, data)
      send(csock, "OK")
   UNTIL data[0] = ESC
   closesocket(csock)
END

FUNCTION clients(sock:SOCKET)
BEGIN
   DO
      csock := accept(sock, csin)
      client(csock)
   FOREVER
END

BEGIN
   sock := socket()
   bind(sock, ALL_IP, 23)
   listen(sock, 5)
   clients(sock)
   closesocket(sock)
END

Ce fonctionnement reste simple, mais il n'accepte qu'une seule connexion à la fois. Par contre, dès qu'elle se termine, le serveur est près à accepter une nouvelle connexion. Les fonctions accept() et recv() sont bloquantes. Si une demande de connexion se fait pendant que le serveur traite un client, la connexion est établie, mais le serveur ne traite pas les données tant que la connexion courante n'est pas terminée. listen(…, 5) permet de traiter jusqu'à 5 demandes simultanées.

III-H-2. Code

Sélectionnez
/* 7s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include
#include
#include
#include
#include              /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include
#include

/* macros ============================================================== */

#define TELNET 23

/* we want to listen to the TELNET port */
#define PORT TELNET

#define ESC 27

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */

struct cli
{
   SOCKADDR_IN sin;
   int recsize;
   SOCKET sock;
   int err;
};

/* private data ======================================================== */
/* private functions =================================================== */

/* thread-like client function */
static void *client (void *p_data)
{
   struct cli *p_cli = p_data;
   if (p_cli != NULL)
   {
      int end = 0;
      do
      {
         /* wait for the receive of a data block */
         unsigned char data[128];
         int sock_err = recv (p_cli->sock, data, (sizeof data - 1), 0);

         if (sock_err != SOCKET_ERROR)
         {
            size_t nb_rec = sock_err;
            if (nb_rec > 0)
            {
               /* convert to string */
               data[nb_rec] = 0;
               printf ("%u byte%s received:\n'%s'\n",
                       (unsigned) nb_rec, nb_rec > 1 ? "s" : "", data);
               fflush (stdout);

               if (data[0] == ESC)
               {
                  end = 1;
               }
               else
               {
                  /* send some YES-TO-ALL answer */
                  char const response[] = "OK\n";
                  send (p_cli->sock, response, strlen (response), 0);
               }
            }
            else
            {
               puts ("client is disconnected");
               end = 1;
            }
         }
         else
         {
            perror ("socket.recv");
            p_cli->err = 1;
            end = 1;
         }
      }
      while (!end);
      shutdown (p_cli->sock, 2);
      printf ("closing client socket %d...\n", p_cli->sock);
      closesocket (p_cli->sock), p_cli->sock = INVALID_SOCKET;
   }

   return NULL;
}

static int clients (SOCKET sock)
{
   int err = 0;
   int end = 0;
   do
   {
      /* wait for a client connection */
      printf ("waiting for a client connection on port %d...\n", PORT);

      {
         /* create a new client context */
         struct cli *p_cli = malloc (sizeof *p_cli);
         if (p_cli != NULL)
         {
            p_cli->recsize = (int) sizeof p_cli->sin;
            p_cli->sock =
               accept (sock, (SOCKADDR *) & p_cli->sin, &p_cli->recsize);

            if (p_cli->sock != INVALID_SOCKET)
            {
               printf
                  ("client connected with socket %d from %s:%d\n",
                   p_cli->sock, inet_ntoa (p_cli->sin.sin_addr),
                   htons (p_cli->sin.sin_port));

               client (p_cli);

            }
            else
            {
               perror ("socket.accept");
               err = 1;
            }

            /* free the client context */
            free (p_cli), p_cli = NULL;
         }
         else
         {
            printf ("client creation failed : memory error\n");
         }
      }
   }
   while (!end);
   return err;
}

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      /* we want to listen on the TELNET port */
      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin = { 0 };

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) & sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               err = clients (sock);
            }
            else
            {
               perror ("socket.listen");
               err = 1;
            }
         }
         else
         {
            perror ("socket.bind");
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      if (!err)
      {
         err = app ();

#if defined (WIN32) || defined (_WIN32)
         WSACleanup ();
#else
      }
#endif
   }
   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }

   return ret;
}

Pour réaliser une serveur plus conséquent, on peut créer un thread (ou un processus) par client connecté.

III-I. 08 - Serveur-multi-clients : réception d'un bloc de données puis deconnexion sur ESC

En rendant autonome la fonction 'client()' et en la transformant en thread, le serveur devient multi-client. En effet, après connexion d'un client, au lieu de rester bloqué sur la réception, le serveur se bloque à nouveau sur l'attente de connexion (accept()). Cela permet de recevoir et de traiter plusieurs demandes de connexions en même temps. Le serveur ne s'arrête jamais.

III-I-1. Pseudo-code

Sélectionnez
STRUCTURE CLIENT
BEGIN
   thread:PTHREAD
   sock:SOCKET
END

THREAD client(data:GENERIC):GENERIC
BEGIN
   cli := CLIENT(data)
   WITH cli
   BEGIN
      REPEAT
         recv(sock, data)
         send(sock, "OK")
      UNTIL data[0] = ESC
      closesocket(sock)
   END
   client := NIL
END

FUNCTION clients(sock:SOCKET)
BEGIN
   DO
      csock := accept(sock, csin)
      pthread_create(cli.thread, client, cli.csock)
   FOREVER
END

BEGIN
   sock := socket()
   bind(sock, ALL_IP, 23)
   listen(sock, 5)
   clients(sock)
   closesocket(sock)
END

Ce fonctionnement reste simple, et il accepte plusieurs connexions à la fois. Les fonctions accept() et recv() sont bloquantes, mais elles sont maintenant dans des threads différents. Dès qu'un client est crée, la fonction 'clients' revient en attente de connexion.

III-I-2. Code

Sélectionnez
/* 08s.c */
#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>             /* close */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

#include <pthread.h>

/* macros ============================================================== */

#define TELNET 23

/* we want to listen to the TELNET port */
#define PORT TELNET

#define ESC 27

/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */

struct cli
{
   pthread_t thread;
   SOCKADDR_IN sin;
   int recsize;
   SOCKET sock;
   int err;
};

/* private data ======================================================== */
/* private functions =================================================== */

/* thread client function */
static void *client (void *p_data)
{
   struct cli *p_cli = p_data;
   if (p_cli != NULL)
   {
      int end = 0;
      do
      {
         /* wait for the receive of a data block */
         unsigned char data[128];
         int sock_err = recv (p_cli->sock, data, (sizeof data - 1), 0);

         if (sock_err != SOCKET_ERROR)
         {
            size_t nb_rec = sock_err;
            if (nb_rec > 0)
            {
               /* convert to string */
               data[nb_rec] = 0;
               printf ("%u byte%s received:\n'%s'\n",
                       (unsigned) nb_rec, nb_rec > 1 ? "s" : "", data);
               fflush (stdout);

               if (data[0] == ESC)
               {
                  end = 1;
               }
               else
               {
                  /* send some YES-TO-ALL answer */
                  char const response[] = "OK\n";
                  send (p_cli->sock, response, strlen (response), 0);
               }
            }
            else
            {
               puts ("client is disconnected");
               end = 1;
            }
         }
         else
         {
            perror ("socket.recv");
            p_cli->err = 1;
            end = 1;
         }
      }
      while (!end);
      shutdown (p_cli->sock, 2);
      printf ("closing client socket %d...\n", p_cli->sock);
      closesocket (p_cli->sock), p_cli->sock = INVALID_SOCKET;

      /* the memory is now under the control of the thread */
      free (p_cli), p_cli = NULL;

   }

   return NULL;
}

static int clients (SOCKET sock)
{
   int err = 0;
   int end = 0;
   do
   {
      /* wait for a client connection */
      printf ("waiting for a client connection on port %d...\n", PORT);

      {
         /* create a new client context */
         struct cli *p_cli = malloc (sizeof *p_cli);
         if (p_cli != NULL)
         {
            p_cli->recsize = (int) sizeof p_cli->sin;
            p_cli->sock =
               accept (sock, (SOCKADDR *) &p_cli->sin, &p_cli->recsize);

            if (p_cli->sock != INVALID_SOCKET)
            {
               printf
                  ("client connected with socket %d from %s:%d\n",
                   p_cli->sock, inet_ntoa (p_cli->sin.sin_addr),
                   htons (p_cli->sin.sin_port));

               /* send ...*/
               pthread_create (&p_cli->thread, NULL, client, p_cli);
               /* ... and forget */
               p_cli = NULL;

            }
            else
            {
               perror ("socket.accept");
               err = 1;
            }
         }
         else
         {
            fprintf (stderr, "client creation failed : memory error\n");
         }
      }
   }
   while (!end);
   return err;
}

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
static int app (void)
{
   int err = 0;

   /* open a socket in TCP/IP mode. */
   SOCKET sock = socket (AF_INET, SOCK_STREAM, 0);

   if (sock != INVALID_SOCKET)
   {
      printf ("socket %d is now opened in TCP/IP mode\n", sock);

      /* we want to listen on the TELNET port */
      {
         int sock_err;
         /* assign the listening port */
         SOCKADDR_IN sin = { 0 };

         /* automatic IP address */
         sin.sin_addr.s_addr = htonl (INADDR_ANY);

         /* protocol family (IP) */
         sin.sin_family = AF_INET;

         /* listening port */
         sin.sin_port = htons (PORT);

         /* bind */
         sock_err = bind (sock, (SOCKADDR *) &sin, sizeof sin);

         if (sock_err != SOCKET_ERROR)
         {
            /* start listening (server mode) */
            sock_err = listen (sock, 5);

            printf ("listening on port %d...\n", PORT);

            if (sock_err != SOCKET_ERROR)
            {
               err = clients (sock);
            }
            else
            {
               perror ("socket.listen");
               err = 1;
            }
         }
         else
         {
            perror ("socket.bind");
            err = 1;
         }

         printf ("closing socket %d...\n", sock);

         /* close the socket. */
         sock_err = closesocket (sock), sock = INVALID_SOCKET;

         printf ("the socket is now closed\n");

         if (sock_err)
         {
            perror ("socket.close");
            err = 1;
         }
      }
   }
   else
   {
      perror ("socket.open");
      err = 1;
   }

   return err;
}

/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;
#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
#else
   int err = 0;
   {
#endif

      err = app ();

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

   if (err)
   {
      ret = EXIT_FAILURE;
   }
   else
   {
      ret = EXIT_SUCCESS;
   }

   system ("pause");

   return ret;
}

III-J. Conclusion

Les bases de la réalisation d'un mini serveur Telnet multi-clients sont jetées. Il suffit maintenant de reconstituer les lignes de commandes, de les transmettre à un interpréteur de commande etc.

Exemple de serveur multi-client simple avec client qui demande l'heure courante (now) ou quitte (quit) :

  • serveur ;
  • client.

IV. Transmettre du texte par sockets

Un socket permet de transmettre des données 'brutes' sous la forme de blocs d'octets caractérisés dans le code par une adresse et une longueur (les paramètres de send(), par exemple).

Des informations de type texte peuvent évidemment se ramener à une structure adresse, longueur. Mais il convient de le faire proprement et en respectant certaines conventions telles que :

  • le jeu de caractères est ASCII ;
  • pas de 0 en ligne ;
  • les chaînes sont terminées par une marque de fin de ligne telle que CR, LF, CRLF ou autre, définie par protocole utilisé.

Le respect de ces conventions permet d'élaborer des fonctions d'émission et réception de texte correctes et efficaces.

La technique utilisée dans ce qui suit n'est valide que sous Windows (winsock2).

IV-A. Emission de petits blocs (cas simple)

Par 'petit blocs', on entend des chaînes dont la longueur est inférieure à une trame (quelques centaines d'octets). Le principe est donc de passer l'adresse du premier élément du tableau de char qui contient la chaîne, et sa longueur mesurée ici avec strlen().

La fonction send() retourne le nombre d'octets effectivement transmis :

Sélectionnez
   char *text = "Hello world\n";
   int n = send (sock, text, strlen (text), 0);
   if (n >= 0)
   {
      /* debug */
      printf ("%d octet%s sent\n", n, n != 1 ? "s" : "");
   }

IV-B. Réception de petits blocs

La réception doit tenir compte du fait qu'en C, les chaînes sont terminées par un 0. Il faut donc, non seulement placer ce 0 "à la main" (puisqu'il n'est pas transmis), mais en plus prévoir de la place pour lui dans la chaîne de réception.

La fonction recv() retourne le nombre d'octets effectivement reçus. Cette valeur est <= à la taille passée en paramètre. Elle sert à positionner le 0. Une valeur < 0 signale une erreur. 0 signifie 'rien reçu' dans le cas d'un socket non bloquant. Dans le cas d'un socket bloquant, en mode TCP, il signifie 'déconnectée'. Cette valeur ne devrait pas exister en mode UDP bloquant.

Sélectionnez
   char text[128]; /* taille arbitraire */
   int n = recv (sock, text, sizeof text - 1, 0);
   if (n > 0)
   {
      text[n] = 0;

      /* debug */
      printf ("received : '%s'\n", text);
   }

IV-C. Émission de gros blocs (cas général)

Pour émettre un gros bloc, il faut utiliser une technique de découpage si on ne veut pas perdre de données. Étant donné qu'en général on ne sait pas quelle est la valeur maximale d'une trame sur la machine, le mieux est de concevoir du code auto-adaptatif. Pour cela, on utilise une information capitale qui est la valeur retournée par send(). En effet, la valeur retournée est le nombre d'octets effectivement émis.

Par exemple, si la trame maximale fait 1024 octets et qu'on demande à en émettre 2000, send() va retourner 1024. A nous de créer un algorithme qui tient compte de ce résultat et envoie la suite des données.

Sélectionnez
   /* non teste */
   size_t const len = strlen (text);
   size_t sent = 0;

   while (sent != len)
   {
      int n = send (socket, text + sent, len - sent, 0);

      if (n >=0)
      {
         sent += n;
      }
      else
      {
         /* erreur de transmission */
      }
   }

Cette technique est générale et couvre évidemment le cas des petits blocs. Il serait intéressant de placer ce code dans une fonction.

IV-D. Réception de gros blocs

La réception assez simple. Il suffit de stocker les blocs reçus et de les passer à une fonction de traitement des données.

Étant donné que l'émission peut avoir découpé les informations en trames plus petites que les informations à transmettre, il faut veiller à ce que les données soient 'réassemblées' sous la forme de lignes cohérentes, c'est à dire terminées par un '\n' si on cherche à interpréter les données reçues.

Les données reçues après un '\n' ne doivent pas être éliminées, mais sont le début de la ligne suivante.

Un mécanisme de reconstitution des lignes doit donc être mis en place en se basant sur la marque de fin de ligne. Si le programme ne fait que router les donnée (par exemple, un serveur de connexion client/client ou une transmission de fichier texte), il pourra se contenter de retransmettre les données inchangées.

IV-E. Étude de cas : transmettre un fichier texte

IV-E-1. Spécifications

Soit à réaliser un logiciel qui transmet un fichier nommé "fichier.txt" contenant un nombre non spécifié de lignes de textes correctement formées (CR, LF, CRLF ou LFCR).

L'accent est mis sur le protocole et l'intégrité des données, et non sur les fonctionnalités qui sont réduites au strict nécessaire.

IV-E-2. Conception

Le logiciel est composé de deux programmes :

  • l'un tourne sur un serveur. Il reçoit la commande de transmission de la part d'un utilisateur et il fourni le fichier à transmettre : c'est le serveur ;
  • l'autre tourne sur une machine utilisateur. Il émet la demande de transmission et reçoit le fichier qu'il stocke localement : c'est le client.

IV-E-3. Protocole

Afin de garantir l'intégrité du transfert, le protocole suivant est établi :

  • un message est une ligne de texte. La fin de ligne peut être CR, LF, CRLF ou LFCR ;
  • le messages peuvent être des commandes ou des réponses ;
  • le client a l'initiative du transfert. C'est lui qui établit la connexion avec le serveur et qui transmet la commande de récupération de fichier ;
  • le serveur répond alors "OK" ou "KO", Si la réponse est OK, le transfert peut commencer dès que le client passe une commande d'exécution. Le transfert se termine lorsque la dernière ligne a été transmise. La connexion est alors fermée par le serveur.

IV-E-4. Format des messages

Sélectionnez
message ::= <texte><EOL>
texte ::= sequence of printable characters (ASCII)
EOL ::= CR | LF | CRLF | LFCR

IV-E-5. Commandes

Le client peut passer les commandes suivantes :

  • get <fichier> ;
  • go.

IV-E-6. Réponses

Le serveur peut émettre les réponses suivantes :

  • ok ;
  • ko.

IV-E-7. Exemple de dialogue

Sélectionnez
Client                                        Serveur
                ------ connexion ----------->
                ------ "get fichier.txt" --->
                <----- "ok" -----------------
fopen(..., "w")
                ------ "go" ---------------->
                                             fopen(..., "r")
                                             fgets() send()
                <----- données --------------
recv()
fputs()
                                             fopen(..., "r")
                                             fgets() send()
                <----- données --------------
recv()
fputs()
                <----- ... ------------------
                                             fclose()
                <----- déconnexion ----------
fclose()

Ce protocole basique devrait fonctionner, mais le fait de compter sur la déconnexion pour fermer le fichier en écriture est un peu risqué. Il y a plusieurs façons d'améliorer le protocole :

  • transmettre le nombre de lignes attendues et fermer quand la dernière ligne a été reçue ;
  • acquitter chaque ligne par un "OK". La dernière est acquittée par un "EOF".

V. Transmettre des données binaires par sockets

La transmission de données binaire doit être 'transparente', c'est à dire qu'on ne doit en aucun cas utiliser les données transmises comme délimiteur. Il n'y a donc pas de marqueur de fin. Il faut donc soit transmettre des données de taille connue (entêtes, par exemple) soit transmettre la taille (dans un en-tête, par exemple), puis les données.

V-A. En-tête

L'en-tête minimum contient la taille du bloc a transmettre. C'est une donnée de taille fixe. Pour la taille, un entier de 32-bit est adapté, codé selon la norme réseau, c'est à dire MSB en tête.

V-B. Protocole

Il est prudent de mettre en place un protocole minimum qui acquitte la transmission de l'entête :

  • taille de l'entête : 4 octets ;
  • acquittement : 1 octet : 00 = OK 01 = ERREUR.

On peut aussi utiliser l'acquittement pour finaliser la réception du bloc de données :

Sélectionnez
Émetteur                 Récepteur
         Taille du bloc
      ------------------>

          Acquittement
      <------------------

            Données
      ------------------>
      ------------------>
      ------------------>
      - - - - - - - - - >

           Acquittement
      <------------------

V-C. Mise en œuvre

Soit à transmettre une bloc de données de 256 octets comprenant les valeurs 0-255 dans l'ordre (ce sera plus simple pour vérifier).

V-C-1. Transmission de l'entête

Il faut tout d'abord créer l'entête, qui est un entier non signé de 32-bit valant 256. Pour cela, on dé-corelle la représentation interne de la représentation réseau de manière portable (indépendante de la représentation interne dans la machine) :

Sélectionnez
{
   /* taille a transmettre */
   unsigned long size = 256;

   /* donnees 'taille' effectivement transmises */
   unsigned char data[4];

   data[0] = (size >> (3 * 8)) & 0xFF; /* MSB (3) en tete (0) */
   data[1] = (size >> (2 * 8)) & 0xFF;
   data[2] = (size >> (1 * 8)) & 0xFF;
   data[3] = (size >> (0 * 8)) & 0xFF;

   /* transmission du bloc : */
   int n = send (sock, data, sizeof data, 0);

   if (n > 0)
   {
      /* transmission réussie */
   }
   else
   {
      /* echec de la transmission */
   }

V-C-2. Réception de l'entête

C'est l'opération inverse. On reçoit un bloc de 4 octets (MSB en tête), et on en déduit la taille :

Sélectionnez
{
   /* données 'taille' effectivement reçues */
   unsigned char data[4];

   /* réception du bloc : */
   int n = recv (sock, data, sizeof data, 0);

   if (n == sizeof data)
   {
      /* réception réussie */
      /* taille a recevoir */
      unsigned long size = 0;

      size |= (data[0] << (3 * 8)) ; /* MSB (3) en tête (0) */
      size |= (data[1] << (2 * 8)) ;
      size |= (data[2] << (1 * 8)) ;
      size |= (data[3] << (0 * 8)) ;

      printf ("%lu bytes attendus\n", size);

   }
   else
   {
      /* échec de la réception */
   }

V-C-3. Transmission et réception de l'acquittement

C'est exactement le même principe, sauf pour une taille de 1, on peut simplifier le code ainsi :

Sélectionnez
enum
{
   REP_ACK = 0,
   REP_NAK = 1,
   dummy
};

/* émission */
{
   /* réponse a transmettre */
   int rep = REP_ACK;

   /* données effectivement transmises */
   unsigned char data[1];

   data[0] = rep;

   /* transmission du bloc : */
   int n = send (sock, data, sizeof data, 0);

   if (n > 0)
   {
      /* transmission réussie */
   }
   else
   {
      /* échec de la transmission */
   }
}

/* réception */
{
   /* données effectivement reçues */
   unsigned char data[1];

   /* réception du bloc : */
   int n = recv (sock, data, sizeof data, 0);

   if (n == sizeof data)
   {
      /* réception réussie */

      /* réponse a recevoir */
      int rep = data[0];

      switch (rep)
      {
      case REP_ACK:
         puts("ACK");

         /* affichage nombre de bytes ou données*/
         break;
      case REP_NAK:
         puts("NAK");
         break;
      default:
         puts("?");
      }
   }
   else
   {
      /* échec de la réception */
   }

V-C-4. Transmission des données

Il faut tout d'abord créer le bloc de données :

Sélectionnez
{
   /* exemple de bloc de données */
   unsigned char data[256];

   int i;

   for (i = 0; i < sizeof data; i++)
   {
      data[i] = i;
   }

ensuite, on émet le bloc de la manière courante :

Sélectionnez
{
   /* transmission du bloc : */
   int n = send (sock, data, sizeof data, 0);

   if (n > 0)
   {
      /* transmission réussie */
   }
   else
   {
      /* échec de la transmission */
   }

V-C-5. Réception des données

En s'appuyant sur l'information 'taille' (size) reçue précédemment, on alloue un bloc d'une taille suffisante, puis on réceptionne les données selon la méthode classique :

Sélectionnez
{
   /* données effectivement reçues */
   unsigned char *data = malloc (size * sizeof *data);

   if (data != NULL)
   {
      /* réception du bloc : */
      int n = recv (sock, data, size, 0);

      if (n == size)
      {
         /* réception réussie */

         /* affichage de contrôle */
         int i;
         for (i = 0; i < n; i++)
         {
            printf ("%4u", data[i]);
            if ((i + 1) % 8 == 0)
            {
               printf ("\n");
            }
         }
         printf ("\n");
      }
      else
      {
         /* échec de la réception */
      }
      free (data);
   }

Une simulation opérationelle se trouve : ici.

Sélectionnez
WIN: winsock2: OK
SIM: Appuyer sur ENTER pour lancer le client
SRV: La socket 88 est maintenant ouverte en mode TCP/IP
SRV: ecoute du port 23...
SRV: Patientez pendant que le client se connecte sur le port 23...

CLI: Connexion a 127.0.0.1 sur le port 23
SRV: Un client se connecte avec la socket 120 de 127.0.0.1:50295
CLI: 256 bytes attendus
SRV: ACK
   0   1   2   3   4   5   6   7
   8   9  10  11  12  13  14  15
  16  17  18  19  20  21  22  23
  24  25  26  27  28  29  30  31
  32  33  34  35  36  37  38  39
  40  41  42  43  44  45  46  47
  48  49  50  51  52  53  54  55
  56  57  58  59  60  61  62  63
  64  65  66  67  68  69  70  71
  72  73  74  75  76  77  78  79
  80  81  82  83  84  85  86  87
  88  89  90  91  92  93  94  95
  96  97  98  99 100 101 102 103
 104 105 106 107 108 109 110 111
 112 113 114 115 116 117 118 119
 120 121 122 123 124 125 126 127
 128 129 130 131 132 133 134 135
 136 137 138 139 140 141 142 143
 144 145 146 147 148 149 150 151
 152 153 154 155 156 157 158 159
 160 161 162 163 164 165 166 167
 168 169 170 171 172 173 174 175
 176 177 178 179 180 181 182 183
 184 185 186 187 188 189 190 191
 192 193 194 195 196 197 198 199
 200 201 202 203 204 205 206 207
 208 209 210 211 212 213 214 215
 216 217 218 219 220 221 222 223
 224 225 226 227 228 229 230 231
 232 233 234 235 236 237 238 239
 240 241 242 243 244 245 246 247
 248 249 250 251 252 253 254 255

SRV: ACK
SRV: Fermeture du socket client

WIN: winsock2: closed

Process returned 0 (0x0)   execution time : 5.338 s
Press any key to continue.

VI. Du bon usage de select()

VI-A. Introduction

La fonction select() suspend l'exécution de la tache courante. Celle-ci reprend si au moins une des conditions suivantes est vérifiée :

  • réception sur un flux (sauf Windows) ou un socket ;
  • fin d'émission sur un flux (sauf Windows) ou un socket ;
  • réception d'un message d'erreur sur un flux (sauf Windows) ou un socket ;
  • échéance de temps.

L'analyse de la valeur retournée permet en partie d'identifier l'événement. (-1 = erreur, 0 = échéance, autre = événement flux)

VI-B. Programmation

Chacune des conditions est programmable individuellement. On utilise pour cela les paramètres de la fonction, qui, il faut le reconnaître, paraissent un peu étranges au début. En fait tout cela est extrêmement simple et logique :

Le premier paramètre est le plus grand numéro de flux surveillé augmenté de 1.

Le deuxième paramètre est un pointeur sur un objet de type fd_set qui contient la liste des flux entrants surveillés (réception de données)

Le troisième paramètre est un pointeur sur un objet de type fd_set qui contient la liste des flux sortants surveillés (émission de données)

Le quatrième paramètre est un pointeur sur un objet de type fd_set qui contient la liste des flux entrants surveillés (réception d'un message hors bandes : supervision)

Le cinquième paramètre est l'adresse d'un objet de type struct timeval qui contient la durée max de la suspension (timeout).

VI-C. Exemples d'utilisation

Ces exemples, un peu scolaires, montrent une possibilité à la fois. Lorsqu'un paramètre n'est pas utilisé, on lui donne la valeur 0 ou NULL.

VI-C-1. Suspension temporisée

Il suffit de régler la valeur de la temporisation. Attention, pour être portable, celle-ci doit être mise à jour à chaque fois, car il est possible que les valeurs de la structure soient altérées par select().

Sélectionnez
   for (;;)
   {
      /* set the timer to 1.5 second */
      struct timeval timeout;

      timeout.tv_sec = 1; /* 1 s */
      timeout.tv_usec = 5 * 100 * 1000; /* 500 ms */

      int err = select (0, NULL, NULL, NULL, &timeout);

      switch (err)
      {
      case 0:
         /* timeout */
         puts ("timeout");
         break;

      case -1:
         /* error */
         puts ("error");
         break;

      default:
         /* stream event */

         /* - (client) connexion has been accepted or data has been received */

         /* - data has been sent */

         /* - an error has been received */
         ;
      }
   }

VI-C-2. Surveillance d'un flux en réception

Cet exemple est purement scolaire, car il n'a pas d'intérêt fonctionnel, étant donné que les fonctions recv() et recvfrom() sont blocantes par défaut. Mais il permet de montrer la syntaxe sur un cas simple. Les macros FD_ZERO() et FD_SET() facilitent la manipulation de l'objet fd_set.

Sélectionnez
   for (;;)
   {
      fd_set readfs;

      FD_ZERO (&readfs);      /* clears readfs */
      FD_SET (sock, &readfs); /* adds a stream */

      int err = select (sock + 1, &readfs, NULL, NULL, NULL);

      switch (err)
      {
      case 0:
         /* timeout */
         break;

      case -1:
         /* error */
         puts ("error");
         break;

      default:
         /* stream event */

         /* - (server) a new client is connected or data has been received */

         if (FD_ISSET (sock, &readfs))
         {
            char data[128];
            int n = recv (sock, data, sizeof data, 0);

            if (n > 0)
            {
               /* process the received data */
            }
         }

         /* - data has been sent */

         /* - an error has been received */

      }
   }

VI-C-3. Surveillance de deux flux en réception

Cet exemple est réel. Il permet de suspendre l'exécution en un endroit précis et unique du logiciel et donc de surveiller la réception de n flux (ici, 2). FD_SETSIZE retourne une valeur correcte mais non optimisée pour le premier paramètre de select().

Sélectionnez
   for (;;)
   {
      fd_set readfs;

      FD_ZERO (&readfs);      /* clears readfs */
      FD_SET (sock_a, &readfs); /* adds a stream */
      FD_SET (sock_b, &readfs); /* adds another stream */

      int err = select (FD_SETSIZE, &readfs, NULL, NULL, NULL);

      switch (err)
      {
      case 0:
         /* timeout */
         break;

      case -1:
         /* error */
         puts ("error");
         break;

      default:
         /* stream event */

         /* - (server) a new client is connected or data has been received */

         if (FD_ISSET (sock_a, &readfs))
         {
            char data[128];
            int n = recv (sock_a, data, sizeof data, 0);

            if (n > 0)
            {
               /* process the received data */
            }
         }

         if (FD_ISSET (sock_b, &readfs))
         {
            char data[128];
            int n = recv (sock_b, data, sizeof data, 0);

            if (n > 0)
            {
               /* process the received data */
            }
         }

         /* - data has been sent */

         /* - an error has been received */

      }
   }

L'usage de FD_SETSIZE pour un serveur surveillant quelques sockets peut s'avérer pénalisant. Dans ce cas, il est préférable de s'en tenir à la définition, c'est à dire la valeur max des sockets surveillés augmentée de 1 :

Sélectionnez
int max_sock = MAX (sock_a, sock_b);

select (max_sock + 1, /* ... */);

VI-C-4. Conclusion

Les bases sont posées. Il ne reste plus maintenant qu'à faire jouer son imagination pour combiner les possibilités en fonction des besoins. Au cours de la conception, ne pas oublier les possibilités offertes par les threads.

VII. Recupérer l'adresse IP du serveur

A chaque serveur est attribué une ou plusieurs IP publiques sur le réseau mondial Internet. Pour communiquer avec tel ou tel serveur, l'usage des sockets implique de connaître une de ces IP publiques. On utilise pour ça la fonction gethostbyname() et les éléments de la structure récupérée (h_*).

Sélectionnez

/* 10.c */

#ifdef __cplusplus
#error Be sure you are using a C compiler...
#endif

#if defined (WIN32) || defined (_WIN32)

#include <winsock2.h>

#elif defined (linux) || defined (_POSIX_VERSION) || defined (_POSIX2_C_VERSION)\
 || defined (_XOPEN_VERSION)

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>             /* close() */
#include <netdb.h>              /* gethostbyname() */

#define INVALID_SOCKET -1
#define SOCKET_ERROR -1

#define closesocket(s) close (s)
typedef int SOCKET;
typedef struct sockaddr_in SOCKADDR_IN;
typedef struct sockaddr SOCKADDR;

#else
#error not defined for this platform
#endif

#include <stdio.h>
#include <stdlib.h>

/* macros ============================================================== */
/* constants =========================================================== */
/* types =============================================================== */
/* structures ========================================================== */
/* private data ======================================================== */
/* private functions =================================================== */
/* entry point ========================================================= */

/* ---------------------------------------------------------------------
   --------------------------------------------------------------------- */
int main (void)
{
   int ret;

#if defined (WIN32) || defined (_WIN32)
   WSADATA wsa_data;
   int err = WSAStartup (MAKEWORD (2, 2), &wsa_data);

   if (!err)
   {
      puts ("WIN: winsock2: OK");
    #else
       int err;
    #endif

       {
          struct hostent *p = gethostbyname ("www.google.com");

          if (p != NULL)
          {
             struct hostent host = *p;
    /* on est tranquille, on a maintenant une copie stable de l'info. */

             printf ("IP de %s\n", host.h_name);

             {
                int i = 0;
                char *ip;

                while ((ip = host.h_addr_list[i]) != NULL)
                {
                   int j;
                   for (j = 0; j < host.h_length; j++)
                   {
                      if (j > 0)
                      {
                         putchar ('.');
                      }
                      printf ("%u", (unsigned) (unsigned char) ip[j]);
                   }
                   putchar ('\n');

                   i++;
                }
             }

          }
       }

#if defined (WIN32) || defined (_WIN32)
      WSACleanup ();
#else
   }
#endif

    if (err)
    {
       ret = EXIT_FAILURE;
    }
    else
    {
       ret = EXIT_SUCCESS;
    }

    return ret;
}

Ce qui donne :

Sélectionnez
WIN: winsock2: OK
IP de www.l.google.com
216.239.59.103
216.239.59.104
216.239.59.147
216.239.59.99

Press ENTER to continue.

On constate que le véritable nom de serveur de google est "www.l.google.com" et que, vu de chez moi, il possède 4 IP publiques.

VIII. Ébauche d'une bibliothèque sockets portable (psock)

VIII-A. Interfaces communes

psock.h psleep.h

VIII-B. Sockets UNix (sun)

sun.h sun_err.itm sun.c

VIII-C. Sockets IP (Internet Protocol)

inet.h inet_err.itm inet.c

IX. Ressources

Ouvrage de références : Image non disponible 'Unix network programming' (tome 1) de W. Richard Stevens

  • le site d'IBM ;
  • le tutoriel rigolo mais bien fourni de Brian "Beej" Hall ;
  • l'excellent tutoriel Les sockets en C de Benjamin Roux de Developpez.com ;
  • l'excellent logiciel libre multi-plateforme d'analyse de réseau WireShark (ex-EtherReal) ;
  • l'excellent site de ressources pour protocoles divers Ip-Relax.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

L'outil MAKE

  • I. Introduction
  • II. Principes
  • III. Exemple de base
  • IV. Ajout de cibles
  • V. Utilisation des variables
    • V-A. Définition d'une variable
    • V-B. Utilisation d'une variable
    • V-C. Amélioration de l'exemple
  • VI. Variables automatiques

Cet article présente un exemple concret d'utilisation de l'outil MAKE dans le cadre du développement d'un projet C par une approche didactique et pragmatique.

I. Introduction

La production de code (compilation, édition de liens) est assez fastidieuse et difficile à gérer 'à la main' (ligne de commande, scripts...) dès que le projet devient conséquent et évolutif (en fait dès qu'il y a plus d'un fichier en jeu).

Il existe différents outils qui peuvent aider à la gestion des projets. le plus simple et le plus connu est sans aucun doute make (ou une de ses variantes) qui est basé sur une certain nombre de principes et de syntaxes simples.

Dans ce document, je m'efforce de n'utiliser que les caractéristiques les plus courantes qui, bien que l'outil ne soit pas normalisé, devraient se retrouver sur la plupart des versions de make (testé avec le make de Borland et le make de GCC sous DOS/Windows).

Ce document ne prétend en aucun cas être exhaustif. Il s'agit de montrer un exemple concret d'utilisation dans le cadre du développement d'un projet C. L'approche reste didactique et pragmatique.

II. Principes

La structure de base est la suivante

Sélectionnez
<cible> : <dependance[s]>
-TAB-&lt;action[s]>

Le fichier qui contient les commandes de l'outil MAKE s'appelle 'makefile' ou 'Makefile' par défaut. L'invocation de 'make' exécute ce fichier si il se trouve sur le répertoire courant.

Sélectionnez
> make

On peut exécuter un autre fichier de commandes, mais il faut alors utiliser le paramètre -f

Sélectionnez
> make -f projet.mak

La cible sera réalisée en fonction des dépendances et au moyen des actions.

Il y a une tabulation avant chaque action.

La cible par défaut est la première de la liste. On mettra donc la cible finale en premier et les moyens d'y arriver à la suite.

Les caractères situés après un # sont ignorés (commentaires)

L'invocation se fait par :

Sélectionnez
> make

Cible par défaut

Sélectionnez
> make <cible>

Cible particulière

III. Exemple de base

Soit la trilogie classique permettant de tester un petit module

Sélectionnez
/* main.c */
#include "inc/f.h"

int main (void)
{
   f ();
   return 0;
}
Sélectionnez
/* f.c */
#include "inc/f.h"
#include <stdio.h>

void f (void)
{
   puts ("hello world");
}
Sélectionnez
#ifndef H_F
#define H_F

/* f.h */

void f (void);

#endif

Le projet est organisé ainsi :

Sélectionnez
+/<projet>
 |
 +-+/inc
 | |
 | +-f.h
 |
 +-+/src
 | |
 | +-f.c
 |
 +-+/tu
   |
   +-+/src
   |   |
   |   +-main.c
   |
   +-+-/out
   | |
   | +-f.o
   | |
   | +-main.o
   | |
   | +-tu.exe
   |
   +-Makefile

Voici une version basique mais commentée du fichier Makefile servant à réaliser le mini projet :

Sélectionnez
# Makefile

# Cible par defaut (make)
# Cible            : executable
# Dépendances      : les fichiers objets
# Actions          : edition de lien

out/tu.exe: out/main.o out/f.o
   gcc out/main.o out/f.o -o out/tu.exe

# Cible            : fichier objet
# Dépendances      : le fichier source et les fichiers inclus
# Actions          : compilation

out/main.o : src/main.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make src/main.c -o out/main.o

out/f.o    : ../src/f.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make ../src/f.c -o out/f.o

IV. Ajout de cibles

Il est possible d'ajouter des cibles supplémentaires permettant une action particulière. Par exemple, l'effacement de tous les fichiers produits, afin de repartir sur des bases 'saines'.

Une cible sans dépendance sera invoquée systématiquement.

Une cible 'supplémentaire' doit être placée après la cible 'par défaut'.

Le Makefile est donc modifié comme ceci :

Sélectionnez
# Makefile

out/tu.exe: out/main.o out/f.o
   gcc out/main.o out/f.o -o out/tu.exe

out/main.o : src/main.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make src/main.c -o out/main.o

out/f.o    : ../src/f.c ../inc/f.h
   gcc -c -I /mesdoc~1/emmanuel/site/make ../src/f.c -o out/f.o

# Cible            : fictive (make clean)
# Dépendances      :
# Actions          : effacement des fichiers produits dans /out

clean:
   del out/*.o
   del out/tu.exe

Pour effacer le projet, il suffit d'invoquer la cible 'clean' :

Sélectionnez
> make clean

'clean' est un nom de cible couramment utilisé mais pas réservé. On aurait pu aussi bien utiliser 'nettoyage'…

V. Utilisation des variables

Le fichier Makefile ci-dessus montre beaucoup de répétitions. Il est possible de simplifier l'écriture et la maintenance de ce fichier en utilisant des 'variables' qui sont en fait plutôt des macros de substitution de texte.

V-A. Définition d'une variable

Sélectionnez
<variable> = <valeur>

V-B. Utilisation d'une variable

Sélectionnez
... $(<variable>) ...

V-C. Amélioration de l'exemple

Les parties répétitives sont 'factorisées' avec des variables. D'autre part, certains éléments pouvant dépendre de la plate-forme de développement font aussi l'objet d'une abstraction (compilateur, paramètres etc.).

Sélectionnez
# <projet>/tu/Makefile

# répertoire projet
DPROJ = /mesdoc~1/emmanuel/site/make

# répertoire de sortie
DOUT = out

# nom de l'exécutable (cible par défaut)
BIN = $(DOUT)/tu.exe

# liste des objets (cibles intermédiaires)
OBJS = $(DOUT)/main.o $(DOUT)/f.o

# chemin des fichiers inclus pour le compilateur
INCLUDES = -I  $(DPROJ)

# liste des dépendances autres que le source
DEPS = ../inc/f.h

# commandes de compilation et d'édition de lien
CC = gcc
LD = gcc

# Commandes simplifiées par l'utilisation des variables.

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $(BIN)

out/main.o : src/main.c  $(DEPS)
   $(CC) -c $(INCLUDES) src/main.c -o out/main.o

out/f.o    : ../src/f.c  $(DEPS)
   $(CC) -c $(INCLUDES) ../src/f.c -o out/f.o

clean:
   del $(OBJS)
   del $(BIN)

Il devient maintenant extrêmement simple d'ajouter des paramètres pour le compilateur :

Sélectionnez
# <projet>/tu/Makefile

DPROJ = /mesdoc~1/emmanuel/site/make
DOUT = out
BIN = $(DOUT)/tu.exe
OBJS = $(DOUT)/main.o $(DOUT)/f.o
INCLUDES = -I  $(DPROJ)
DEPS = ../inc/f.h
CC = gcc
LD = gcc
CFLAGS = -W -Wall -O1 $(INCLUDES)

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $(BIN)

out/main.o : src/main.c $(DEPS)
   $(CC) $(CFLAGS) -c src/main.c -o out/main.o

out/f.o    : ../src/f.c $(DEPS)
   $(CC) $(CFLAGS)  -c ../src/f.c -o out/f.o

clean:
   del $(OBJS)
   del $(BIN)

VI. Variables automatiques

Il existe d'autres variables prédéfinies ou automatiques permettant de simplifier encore le Makefile. Voici une liste indicative :

$@ | Nom de la cible
$^ | Liste des dépendances
$< | Nom de la première dépendance

Attention, le résultat n'est pas garanti avec tous les outils make. Voici les sorties générées par différents outils make à partir de ce fichier de test :

Sélectionnez
# <projet>/Makefile
# Test des variables automatiques

cible.x: dep1.a dep2.b dep3.c
   @echo "@" : $@
   @echo "^" : $^
   @echo "<" : $<

dep1.a :
   @echo $@ > $@

dep2.b :
   @echo $@ > $@

dep3.c :
   @echo $@ > $@

clean:
   del dep*.*
Sélectionnez
GNU Make 3.80
Copyright (C) 2002  Free Software Foundation, Inc.

"@" : cible.x
"^" : dep1.a dep2.b dep3.c
"<" : dep1.a
Sélectionnez
MAKE Version 3.6  Copyright (c) 1992 Borland International

Available memory 131387392 bytes

"@" : cible.x
"^" : $^
"<" : cible.x

Si on utilise GNU make, on peut simplifier le Makefile comme ceci :

Sélectionnez
# <projet>/tu/Makefile

DPROJ = /mesdoc~1/emmanuel/site/make
DOUT = out
BIN = $(DOUT)/tu.exe
OBJS = $(DOUT)/main.o $(DOUT)/f.o
DEPS = ../inc/f.h
INCLUDES = -I  $(DPROJ)
CC = gcc
LD = gcc
CFLAGS = -W -Wall -O1 $(INCLUDES)

$(BIN): $(OBJS)
   $(LD) $(OBJS) -o $@

out/main.o : src/main.c $(DEPS)
   $(CC) $(CFLAGS) -c $< -o $@

out/f.o    : ../src/f.c  $(DEPS)
   $(CC) $(CFLAGS)  -c $< -o $@

clean:
   del $(OBJS)
   del $(BIN)

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Utilisation de la bibliothèque pthread

POSIX threads

  • I. Systèmes multitâches
    • I-A. Introduction
    • I-B. Processus
      • I-B-1. Exécution
      • I-B-2. Création dynamique de processus
    • I-C. Processus légers
  • II. Introduction
    • II-A. Note pour Windows
    • II-B. Objectif
  • III. Hello worlds
  • IV. Données
    • IV-A. Éviter les globales 'à-la-barbare'
    • IV-B. Un seul code pour plusieurs tâches
    • IV-C. Le cas de l'écrivain et du lecteur
  • V. Synchronisation
  • VI. Résumé des types et fonctions utilisés
  • VII. Ressources

Cet article fournit les bases de l'utilisation de la bibliothèque pthread (POSIX threads) par une approche pragmatique.

I. Systèmes multitâches

I-A. Introduction

Force est de constater qu'un programme courant (sauf traitements lourds comme un scan d'antivirus) passe 90 à 99% de son temps à attendre des événements extérieurs, notamment, évidemment, les entrées (stdin, lecture fichier, attentes de données reçues sur un socket...) à moins qu'on ait voulu délibérément retarder l'exécution de telle ou telle action (sleep(), Sleep() etc.).

Donc, un processeur est, la plupart du temps, en train de ne rien faire (ou alors des boucles inutiles). Dès les débuts de l'informatique, cette constatation a amené les informaticiens à réfléchir à la meilleure façon d'utiliser ce temps libre.

L'idée de répartir le temps du processeur entre plusieurs application s'est alors imposée.

Les performances des systèmes modernes reposent en grande partie sur leur possibilité de charger et exécuter plusieurs applications 'en même temps'. Cette simultanéité n'est évidemment qu'apparente, sur une machine mono-processeur. Mais elle a l'avantage d'exister et d'être efficace, et nous le constatons tous les jours sur notre PC ou notre téléphone mobile.

I-B. Processus

Un processus est une application (ou un programme) en cours d'exécution. Un programme, est avant tout un fichier stocké sur un disque quelconque contenant du code exécutable.

Pour pouvoir être exécuté, il faut réunir un certain nombre de conditions :

  • charger le programme en mémoire ;
  • réserver de l'espace pour les données statiques ;
  • réserver de l'espace dans la pile ;
  • initialiser les pointeurs d'instructions et de pile ;
  • lancer l'exécution.

A un processus est donc associé un espace mémoire composé d'un programme (code exécutable) et de données (statiques et pile).

I-B-1. Exécution

Dans un système multitâches comme Unix ou Windows (mais pas MS-DOS), «lancer l'exécution» signifie rendre le processus «éligible», c'est à dire que dès que le processeur est libre, il pourra l'exécuter réellement (il devient alors «élu»).

Dès que le programme rencontre une fonction qui le fait 'attendre' (suspension temporisée, attente d'une entrée etc.) le système (en fait, l'ordonnanceur, qui est le mécanisme de répartition du temps machine) en profite pour suspendre l'exécution du processus («suspendu»). Le premier processus éligible passe alors en mode «élu». (il «prend la main» comme on dit vulgairement).

Il faut savoir qu'il existe certaines tâches dites 'immédiates' (interruptions) qui peuvent s'exécuter en temps réel à chaque instant. C'est notamment le cas pour les traitements de bas niveau de type timer (déterminer une échéance) ou clavier, réseau, disque etc. Les événements détectes sont transmis au système qui détermine alors à quel processus suspendu ils correspondent et si il s'agit bien d'un événement de déblocage. ("en attente d'un <enter>", "en attente d'une trame réseau" etc). Si c'est le cas, le processus passe en état «éligible». La suite, on la connaît.

En résumé, les états d'un processus sont :

Image non disponible

Il peut y avoir des schémas plus complexes, mais le principe est là.

I-B-2. Création dynamique de processus

Il est possible par logiciel de créer une 'copie' ou un 'fils' du processus courant. Cela revient à créer un nouvel espace mémoire contenant une copie des données (le code reste unique).

Les valeurs des données du processus fils sont celles de l'instant de la copie (sous unixoïde, fork()). Si le processus fils modifie ses données, les données du père sont inchangées. De même, si le père modifie ses données, les données du fils sont inchangées. Les deux espaces mémoires sont complètement étanches. au point que si il fallait échanger des données entre les deux processus, il faudrait utiliser des mécanismes assez complexes (IPC : Inter Processus Communication, SHM : SHared Memory, sockets UNIX etc).

I-C. Processus légers

Dans un processus donné, il est possible de créer un processus léger appelé aussi thread qui est un espace mémoire limité à une simple pile et un code exécutable (la fonction du thread). La mémoire statique est celle du processus courant. Si plusieurs threads sont créés, ils partagent le même espace de mémoire statique, mais ils ont chacun leur propre pile (variables locales).

Ce mécanisme est simple et permet d'écrire facilement des applications multitâches. Les données statiques étant partagées, leur accès est direct (mais on évite les globales, on n'est pas des barbares)

Revers de la médaille, les accès aux données étant directs, et des accès concurrents étant possibles, les données peuvent être incorrectes si des précautions ne sont pas prises. Il existe donc des mécanismes de protection qui permettent de réguler l'accès aux données (sémaphores).

La gestion des threads a été normalisée par POSIX.1. C'est l'API pthreads.

II. Introduction

La norme POSIX.1 fournit un ensemble de primitives permettant de réaliser des processus légers. Contrairement aux processus habituels, la mémoire est commune. Ces processus légers sont communément appelés 'threads' ou 'tâches'. Selon l'implémentation, l'ensemble des primitives est, par exemple, regroupé dans la bibliothèque libpthread.a (gcc) qu'il faut bien sûr penser à ajouter au projet (-lpthread).

II-A. Note pour Windows

Les pthreads sont disponibles sur ce site.

II-B. Objectif

Le but de cet article est de fournir les bases de l'utilisation de la bibliothèque pthread par une approche pragmatique.

III. Hello worlds

Il est simple de réaliser la création, l'exécution et la fin de 2 tâches 'concurrentes'[1]. Pour cela, on utilise le type opaque (ADT) pthread_t qui va contenir le contexte de chaque tâche, et une fonction de création de tâche pthread_create() qui va associer la tâche avec son contexte, et informer l'ordonnanceur du système de son existence. L'ordonnanceur lancera la tâche dès que possible

[1] En fait, la concurrence est une notion relative. Sur un système mono-processeur, l'exécution des tâches se fait selon des critères de temps (time slicing) ou de suspensions.

Sélectionnez
/* ATTENTION CODE ERRONE */

#include <stdio.h>

#include <pthread.h>

static void *task_a (void *p_data)
{
   puts ("Hello world A");

   (void) p_data;
   return NULL;
}

static void *task_b (void *p_data)
{
   puts ("Hello world B");

   (void) p_data;
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   puts ("main init");

   pthread_create (&ta, NULL, task_a, NULL);
   pthread_create (&tb, NULL, task_b, NULL);

   puts ("main end");

   return 0;
}

La sortie est, par exemple, celle-ci. Attention, l'ordre dans lequel l'exécution des tâches se fait n'est pas défini (normal, puisque dans l'idéal, elles sont concurrentes).

Sélectionnez
main init
main end
Hello world A
Hello world B

Il est important de bien interpréter les lignes produites. Dans cet exemple, il y a un processus (l'application) et 3 tâches :

  • main() ;
  • task_a() ;
  • task_b().

Il est clair, à la vue des lignes produites, que la tâche main() se termine avant que les autres ne soient lancées. Cela signifie que, étant donné que le contexte des tâches n'existe plus, le comportement des tâches devient indéterminé. C'est pourquoi il existe la fonction de synchronisation pthread_join() qui permet de suspendre l'exécution de la tâche courante en attendant la fin d'une tâche déterminée.

Sélectionnez
#include <stdio.h>

#include <pthread.h>

static void *task_a (void *p_data)
{
   puts ("Hello world A");

   (void) p_data;
   return NULL;
}

static void *task_b (void *p_data)
{
   puts ("Hello world B");

   (void) p_data;
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   puts ("main init");

   pthread_create (&ta, NULL, task_a, NULL);
   pthread_create (&tb, NULL, task_b, NULL);

#if 1
   pthread_join (ta, NULL);
   pthread_join (tb, NULL);
#endif

   puts ("main end");

   return 0;
}

La sortie donne maintenant ceci :

Sélectionnez
main init
Hello world A
Hello world B
main end

Dans la réalité, une tâche est généralement une boucle (infinie ou non).

Sélectionnez
#include <stdio.h>

#include <pthread.h>

static void *task_a (void *p_data)
{
   int i;

   for (i = 0; i < 5; i++)
   {
      printf ("Hello world A (%d)\n", i);
   }

   (void) p_data;
   return NULL;
}

static void *task_b (void *p_data)
{
   int i;

   for (i = 0; i < 7; i++)
   {
      printf ("Hello world B    (%d)\n", i);
   }
   (void) p_data;
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   puts ("main init");

   pthread_create (&ta, NULL, task_a, NULL);
   pthread_create (&tb, NULL, task_b, NULL);

#if 1
   pthread_join (ta, NULL);
   pthread_join (tb, NULL);
#endif

   puts ("main end");

   return 0;
}
  1. Les informations produites sont variables. Par exemple :
Sélectionnez
main init
Hello world A (0)
Hello world B    (0)
Hello world B    (1)
Hello world A (1)
Hello world A (2)
Hello world B    (2)
Hello world B    (3)
Hello world A (3)
Hello world A (4)
Hello world B    (4)
Hello world B    (5)
Hello world B    (6)
main end

ou :

Sélectionnez
main init
Hello world A (0)
Hello world B    (0)
Hello world B    (1)
Hello world A (1)
Hello world A (2)
Hello world B    (2)
Hello world B    (3)
Hello world B    (4)
Hello world A (3)
Hello world A (4)
Hello world B    (5)
Hello world B    (6)
main end

Mais on constate que le mécanisme de rendez-vous fonctionne et que les deux boucles sont terminées correctement. Sans le mécanisme de synchronisation :

Sélectionnez
#if 0
   pthread_join (ta, NULL);
   pthread_join (tb, NULL);
#endif

on aurait, par exemple, obtenu ceci, ce qui n'est pas loin d'une catastrophe…

Sélectionnez
main init
main end
Hello world A (0)
Hello world A (1)
Hello world B    (0)
Hello world B    (1)
Hello world B    (2)
Hello world A (2)
Hello world A (3)
Hello world B    (3)
Hello world B    (4)
Hello world A

IV. Données

L'application étant formée d'un seul processus, les données sont communes à toutes les tâches. Cela signifie qu'une donnée est partageable directement entre les processus. Le terme 'données communes' recouvre comme il se doit :

  • les données statiques ;
  • les données allouées ;
  • les données locales.

IV-A. Éviter les globales 'à-la-barbare'

Il est possible d'utiliser le 4ème paramètre de pthread_create() pour passer l'adresse d'un objet quelconque à une tâche (variable simple, tableau, structure) au moment de la création de la tâche. Il est bien évident que la durée de vie de cette variable doit être supérieure ou égale à la durée de vie de la tâche qui l'utilise.

Sélectionnez
#include <stdio.h>

#include <pthread.h>

static void *task_a (void *p_data)
{
   if (p_data != NULL)
   {
      /* récupérer le contexte applicatif */
      char const *s = p_data;
      int i;

      for (i = 0; i < 5; i++)
      {
         printf ("'%s' (%d)\n", s, i);
      }
   }
   return NULL;
}

static void *task_b (void *p_data)
{
   if (p_data != NULL)
   {
      /* récupérer le contexte applicatif */
      char const *s = p_data;
      int i;

      for (i = 0; i < 7; i++)
      {
         printf ("'%s'     (%d)\n", s, i);
      }
   }
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   puts ("main init");

/* La chaîne étant définie const sur ma machine, je force en (char*).
 * Dans la tâche, 's' est bien de type 'char const *'
 */
   pthread_create (&ta, NULL, task_a, (char*)"Tache A");
   pthread_create (&tb, NULL, task_b, (char*)"Tache B");

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

qui produit par exemple :

Sélectionnez
main init
'Tache A' (0)
'Tache B'     (0)
'Tache B'     (1)
'Tache A' (1)
'Tache A' (2)
'Tache A' (3)
'Tache B'     (2)
'Tache B'     (3)
'Tache B'     (4)
'Tache A' (4)
'Tache B'     (5)
'Tache B'     (6)
main end

IV-B. Un seul code pour plusieurs tâches

Il est possible de lancer plusieurs fois la même tâche :

Sélectionnez
#include <stdio.h>

#include <pthread.h>

static void *task (void *p_data)
{
   int i;

   for (i = 0; i < 5; i++)
   {
      printf ("Hello world (%d)\n", i);
   }

   (void) p_data;
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   puts ("main init");

   pthread_create (&ta, NULL, task, NULL);
   pthread_create (&tb, NULL, task, NULL);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

Ce qui donne, par exemple :

Sélectionnez
main init
Hello world (0)
Hello world (1)
Hello world (2)
Hello world (3)
Hello world (4)
Hello world (0)
Hello world (1)
Hello world (2)
Hello world (3)
Hello world (4)
main end

Process returned 0 (0x0)   execution time : 0.041 s
Press any key to continue.

Évidemment, on ne sait plus différencier les traitements, et on ne sait pas qui fait quoi. Mais grâce au paramètre 'données', on va pouvoir personnaliser le fonctionnement des tâches :

  • Identificateur ("A" ou "B") ;
  • Nombre de tours de boucles ;
  • etc.

Comme il y a plusieurs paramètres, on définit une structure, on crée une instance par tâche avec les paramètres voulus et on passe l'adresse de la structure au moment de la création de la tâche. Ensuite la tâche récupère l'adresse en paramètre. Il suffit alors d'initialiser un pointeur du bon type avec l'adresse, et on a alors accès aux champs.

Par exemple :

Sélectionnez
#include <stdio.h>

#include <pthread.h>

struct data
{
   char const *id;
   int n;
};

static void *task (void *p_data)
{
   if (p_data != NULL)
   {
      struct data *p = p_data;
      int i;

      for (i = 0; i < p->n; i++)
      {
         printf ("Hello world %s (%d)\n", p->id, i);
      }
   }

   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;
   struct data data_a = { "A", 5 };
   struct data data_b = { "B", 7 };

   puts ("main init");

   pthread_create (&ta, NULL, task, &data_a);
   pthread_create (&tb, NULL, task, &data_b);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

Qui donne, par exemple (Vista SP1)

Sélectionnez
main init
Hello world A (0)
Hello world A (1)
Hello world A (2)
Hello world A (3)
Hello world A (4)
Hello world B (0)
Hello world B (1)
Hello world B (2)
Hello world B (3)
Hello world B (4)
Hello world B (5)
Hello world B (6)
main end

Process returned 0 (0x0)   execution time : 0.032 s
Press any key to continue.

Les essais on été faits sous Windows Vista SP1, qui ne semble pas pratiquer le time slicing... Chaque boucle va donc jusqu'au bout…

Pour un fonctionnement plus réaliste, on peut introduire une suspension dans les traitements :

Sélectionnez
#include <stdio.h>

#include <pthread.h>

/* http://www.bien-programmer.fr/clib/psleep/ */
#include "psleep/inc/psleep.h"

struct data
{
   char const *id;
   int n;
};

static void *task (void *p_data)
{
   if (p_data != NULL)
   {
      struct data *p = p_data;
      int i;

      for (i = 0; i < p->n; i++)
      {
         printf ("Hello world %s (%d)\n", p->id, i);
         msleep(1);
      }
   }

   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;
   struct data data_a = { "A", 5 };
   struct data data_b = { "B", 7 };

   puts ("main init");

   pthread_create (&ta, NULL, task, &data_a);
   pthread_create (&tb, NULL, task, &data_b);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

Ce qui donne, par exemple (Vista SP1) :

Sélectionnez
main init
Hello world B (0)
Hello world A (0)
Hello world B (1)
Hello world A (1)
Hello world B (2)
Hello world A (2)
Hello world A (3)
Hello world B (3)
Hello world B (4)
Hello world A (4)
Hello world B (5)
Hello world B (6)
main end

Process returned 0 (0x0)   execution time : 0.026 s
Press any key to continue.

msleep() n'est pas standard. C'est une macro que j'ai inventée qui permet d'écrire des suspensions standards sous Windows et unixoides. Les détails sont ici.

IV-C. Le cas de l'écrivain et du lecteur

Soit une variable partagée qui est modifiée par une tâche, et lue par une autre. Le code 'naïf' suivant :

Sélectionnez
#include <stdio.h>

#include <pthread.h>

struct shared
{
   int data;
};

struct data
{
   /* paramètres */
   int nb;
   char const *sid;

   /* contexte partage' */
   struct shared *psh;
};

static void *task_w (void *p)
{
   if (p != NULL)
   {
      /* recuperer le contexte applicatif */
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
         int x = p_data->psh->data;
         x++;
         p_data->psh->data = x;

         printf ("'%s' (%d) data <- %d\n", p_data->sid, i, p_data->psh->data);
      }
   }
   return NULL;
}

static void *task_r (void *p)
{
   if (p != NULL)
   {
      /* récupérer le contexte applicatif */
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
         printf ("                        "
                 "'%s' (%d) data == %d\n", p_data->sid, i, p_data->psh->data);
      }
   }
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   struct shared sh =
   {
      .data = 0,
   };

   struct data da =
   {
      .nb = 5,
      .sid = "Writer",
      .psh = &sh,
   };

   struct data db =
   {
      .nb = 7,
      .sid = "Reader",
      .psh = &sh,
   };

   puts ("main init");

   pthread_create (&ta, NULL, task_w, &da);
   pthread_create (&tb, NULL, task_r, &db);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

provoque des aberrations comme :

Sélectionnez
main init
'Writer' (0) data <- 1
                        'Reader' (0) data == 1
                        'Reader' (1) data == 2
'Writer' (1) data <- 2
'Writer' (2) data <- 3
                        'Reader' (2) data == 2
                        'Reader' (3) data == 4
'Writer' (3) data <- 4
'Writer' (4) data <- 5
                        'Reader' (4) data == 4
                        'Reader' (5) data == 5
                        'Reader' (6) data == 5
main end

où l'on constate que les valeurs lues sont parfois fausses par rapport aux valeurs réelles.

Pour se prémunir contre ce problème, on doit évaluer quelle est la 'zone critique', et ensuite la protéger avec un 'sémaphore'. C'est tout simplement un mécanisme qui va empêcher une autre tâche d'interrompre la section critique. Dans l'environnement pthread, ce mécanisme est appelé mutex. Il met en œuvre le type opaque pthread_mutex_t et les fonctions pthread_mutex_lock() et pthread_mutex_unlock().

Voici un exemple de protection de la section critique de l'écrivain (empêcher le lecteur d'accéder en lecture pendant l'écriture).

Sélectionnez
#include <stdio.h>

#include <pthread.h>

struct shared
{
   int data;
   pthread_mutex_t mut;
};

struct data
{
   /* paramètres */
   int nb;
   char const *sid;

   /* contexte partage' */
   struct shared *psh;
};

static void *task_w (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {

/* debut de la zone critique */
         pthread_mutex_lock (&p_data->psh->mut);
         {
            int x = p_data->psh->data;
            x++;
            p_data->psh->data = x;
         }
         printf ("'%s' (%d) data <- %d\n", p_data->sid, i, p_data->psh->data);
         pthread_mutex_unlock (&p_data->psh->mut);
/* fin de la zone critique */

      }
   }
   return NULL;
}

static void *task_r (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
         printf ("                        "
               "'%s' (%d) data == %d\n", p_data->sid, i, p_data->psh->data);
      }
   }
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   struct shared sh =
   {
      .data = 0,
      .mut = PTHREAD_MUTEX_INITIALIZER,
   };

   struct data da =
   {
      .nb = 5,
      .sid = "Writer",
      .psh = &sh,
   };

   struct data db =
   {
      .nb = 7,
      .sid = "Reader",
      .psh = &sh,
   };

   puts ("main init");

   pthread_create (&ta, NULL, task_w, &da);
   pthread_create (&tb, NULL, task_r, &db);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

L'amélioration est flagrante, mais insuffisante :

Sélectionnez
main init
'Writer' (0) data <- 1
'Writer' (1) data <- 2
'Writer' (2) data <- 3
                        'Reader' (0) data == 3
                        'Reader' (1) data == 4
'Writer' (3) data <- 4
'Writer' (4) data <- 5
                        'Reader' (2) data == 4
                        'Reader' (3) data == 5
                        'Reader' (4) data == 5
                        'Reader' (5) data == 5
                        'Reader' (6) data == 5
main end

Il faut aussi protéger la lecture, c'est à dire empêcher l'écrivain d'écrire pendant la lecture :

Sélectionnez
#include <stdio.h>

#include <pthread.h>

struct shared
{
   int data;
   pthread_mutex_t mut;
};

struct data
{
   /* paramètres */
   int nb;
   char const *sid;

   /* contexte partage' */
   struct shared *psh;
};

static void *task_w (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {

/* début de la zone critique */
         pthread_mutex_lock (&p_data->psh->mut);
         {
            int x = p_data->psh->data;
            x++;
            p_data->psh->data = x;
         }
         printf ("'%s' (%d) data <- %d\n", p_data->sid, i, p_data->psh->data);
         pthread_mutex_unlock (&p_data->psh->mut);
/* fin de la zone critique */

      }
   }
   return NULL;
}

static void *task_r (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
/* début de la zone critique */
         pthread_mutex_lock (&p_data->psh->mut);
         printf ("                        "
               "'%s' (%d) data == %d\n", p_data->sid, i, p_data->psh->data);
         pthread_mutex_unlock (&p_data->psh->mut);
/* fin de la zone critique */
      }
   }
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   struct shared sh =
   {
      .data = 0,
      .mut = PTHREAD_MUTEX_INITIALIZER,
   };

   struct data da =
   {
      .nb = 5,
      .sid = "Writer",
      .psh = &sh,
   };

   struct data db =
   {
      .nb = 7,
      .sid = "Reader",
      .psh = &sh,
   };

   puts ("main init");

   pthread_create (&ta, NULL, task_w, &da);
   pthread_create (&tb, NULL, task_r, &db);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

Le comportement est maintenant conforme aux attentes :

Sélectionnez
main init
'Writer' (0) data <- 1
                        'Reader' (0) data == 1
'Writer' (1) data <- 2
                        'Reader' (1) data == 2
'Writer' (2) data <- 3
                        'Reader' (2) data == 3
'Writer' (3) data <- 4
                        'Reader' (3) data == 4
'Writer' (4) data <- 5
                        'Reader' (4) data == 5
                        'Reader' (5) data == 5
                        'Reader' (6) data == 5
main end

V. Synchronisation

Nous avons pu constater qu'en environnement multitâches, l'ordre d'exécution des instructions de deux tâches différentes était indéterminé. C'est pourquoi il existe un mécanisme permettant la synchronisation. Par exemple, bloquer la tâche courante en attente d'un événement ou la débloquer dès le déclenchement de cet événement.

Dans l'environnement pthread, on parle de 'condition'. L'objet est pthread_cond_t, les fonctions sont pthread_cond_wait() et pthread_cond_signal().

Dans l'exemple initial, les deux tâches s'exécutaient de façon désordonnée. Voici un mécanisme de synchronisation de la tâche B par la tâche A. Il a fallu ajouter une suspension dans la tâche A afin de laisser du temps à B de s'exécuter.

Sélectionnez
#include <stdio.h>

#include <pthread.h>

/* http://www.bien-programmer.fr/clib/psleep/ */
#include "psleep/inc/psleep.h"

struct shared
{
   pthread_mutex_t mut;
   pthread_cond_t synchro;
};

struct data
{
   /* paramètres */
   int nb;
   char const *sid;

   /* contexte partage' */
   struct shared *psh;
};

static void *task_a (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      struct shared *psh = p_data->psh;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
         pthread_mutex_lock (&psh->mut);
         printf ("'%s' (%d)\n", p_data->sid, i);
         pthread_cond_signal (&psh->synchro);
         pthread_mutex_unlock (&psh->mut);
         msleep (1000);
      }
   }
   return NULL;
}

static void *task_b (void *p)
{
   if (p != NULL)
   {
      struct data *p_data = p;
      struct shared *psh = p_data->psh;
      int i;

      for (i = 0; i < p_data->nb; i++)
      {
         pthread_mutex_lock (&psh->mut);
         pthread_cond_wait (&psh->synchro, &psh->mut);
         printf ("                        "
                 "'%s' (%d)\n", p_data->sid, i);
         pthread_mutex_unlock (&psh->mut);
      }
   }
   return NULL;
}

int main (void)
{
   pthread_t ta;
   pthread_t tb;

   struct shared sh =
   {
      .mut = PTHREAD_MUTEX_INITIALIZER,
      .synchro = PTHREAD_COND_INITIALIZER,
   };

   struct data da =
   {
      .nb = 5,
      .sid = "task A",
      .psh = &sh,
   };

   struct data db =
   {
      .nb = 4,
      .sid = "Task B",
      .psh = &sh,
   };

   puts ("main init");

   pthread_create (&ta, NULL, task_a, &da);
   pthread_create (&tb, NULL, task_b, &db);

   pthread_join (ta, NULL);
   pthread_join (tb, NULL);

   puts ("main end");

   return 0;
}

VI. Résumé des types et fonctions utilisés

Types et fonctions | Description
pthread_t | Contexte de tâche
pthread_create() | Création d'une tâche
pthread_join() | Attente de fin de tâche
pthread_mutex_t | Sémaphore d'exclusion mutuelle
pthread_mutex_lock() | Début de zone critique
pthread_mutex_unlock() | Fin de zone critique
pthread_cond_t | Condition
pthread_cond_wait() | Mise en attente
pthread_cond_signal() | Déclenchement

Les essais ont été faits sous Windows 98 avec Dev-C++ et sa bibliothèque pthread. L'ordonnanceur de Windows étant préemptif, les aberrations de fonctionnement sont claires et évidentes. Les essais réalisés sous Linux embarqué (Kernel 2.4 pour Power PC) sont moins parlants, car sur ma configuration de test, l'ordonnanceur n'est pas préemptif. Il semble aussi que, sous Windows XP, l'ordonnanceur ne soit pas préemptif (time slicing), mais coopératif… A moins que j'interprète mal le fonctionnement... Remarques bienvenues.

Les remarques reçues font état des faits suivants :

  • l'ordonnanceur de GNU/Linux 2.4 est soit coopératif, soit préemptif selon une option de compilation du noyau ;
  • l'ordonnanceur de GNU/Linux 2.6 est préemptif ;
  • l'ordonnanceur de MS/Windows 98 est coopératif (blocage possible par une 'boucle blanche') ;
  • l'ordonnanceur de MS/Windows NT/XP est préemptif (événements + time slicing).

VII. Ressources

Pour approfondir, je recommande cet excellent site POSIX Threads Programming.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Les listes chaînées en langage C

  • I. Introduction
  • II. Implémentation d'une liste chaînée simple
    • II-A. Implémentation avec structure

Cet article présente les listes chaînées qui constituent une alternative intéressante aux tableaux.

I. Introduction

Les listes chaînées constituent une alternative intéressante aux tableaux. En dehors du fait qu'elles sont souples par nature, elles permettent d'insérer et de supprimer facilement un élément. Par contre, le parcours est séquentiel (mais rien n'empêche de gérer un 'index', c'est à dire un tableau de pointeurs, séparément).

II. Implémentation d'une liste chaînée simple

Le principe est très simple. Il suffit de définir dans l'élément un champ 'pointeur sur le suivant' qui contient l'adresse de l'élément suivant. Il suffit alors de connaître le début de la liste, et on peut alors la parcourir séquentiellement en allant chercher l'élément suivant, jusqu'au dernier qui pointe sur NULL (fin de liste).

On commence par définir un 'maillon' ou nœud' (node) :

Sélectionnez
struct node
{
   /* données */
   int x;

   /* chainage */
   struct node *p_next;
};

Ensuite, on définit le pointeur de tête. Il a le même type qu'un maillon et il contient soit NULL (chaîne vide), soit l'adresse du premier nœud :

Sélectionnez
int main (void)
{
   struct node *p_head = NULL;

   return 0;
}

Il est primordial que cette valeur soit maintenue à jour et qu'elle ne soit pas perdue.

Ensuite, on crée une fonction qui permet, par exemple, d'ajouter en fin de liste. Pour ça, on distingue le cas de la liste vide et les autres cas. Si la liste est vide, on retourne simplement l'adresse du nouveau nœud qui est par définition, le premier, sinon, on cherche le dernier nœud existant et on y 'accroche' le nouveau.

Sélectionnez
struct node *add_end (struct node *p_head, int value)
{
   /* allocation du nœud */
   struct node *p_new = malloc (sizeof *p_new);

   /* si tout s'est bien passé : */
   if (p_new != NULL)
   {
      /* mise a jour des champs : */

      /* données */
      p_new->x = value;

      /* chaînage par défaut */
      p_new->p_next = NULL;

      /* chaînage */
      if (p_head == NULL)
      {
         /* c'est le premier : */
         p_head = p_new;
      }
      else
      {
         /* on cherche le dernier nœud */
         struct node *p = p_head;

         while (p->p_next != NULL)
         {
            /* pointer sur le suivant */
            p = p->p_next;
         }

         /* modification du chaînage */
         p->p_next = p_new;
      }
   }
   return p_head;
}

Afin de pouvoir tout de suite vérifier si tout s'est bien passé, on définit dans la foulée, une fonction de visualisation de la liste chaînée :

Sélectionnez
void display (struct node *p_head)
{
   struct node *p = p_head;

   while (p != NULL)
   {
      /* afficher les données courantes */
      printf ("%d > ", p->x);

      /* pointer sur le suivant */
      p = p->p_next;
   }
   /* afficher la fin */
   printf ("NIL\n");
}

Avec ce petit fichier de test :

Sélectionnez
int main (void)
{
   struct node *p_head = NULL;

   p_head = add_end (p_head, 1);
   p_head = add_end (p_head, 2);
   p_head = add_end (p_head, 3);
   display (p_head);
   return 0;
}

Cela donne :

Sélectionnez
1 > 2 > 3 > NIL

Press ENTER to continue.

Je laisse au lecteur le soin de réaliser la fonction de libération de l'ensemble de la liste, l'ajout d'un nœud en tête (add_top()), l'insertion (insert()), la suppression d'un nœud (suppress()) etc.

II-A. Implémentation avec structure

Afin de simplifier la gestion de la liste, il est possible de regrouper, dans une structure 'liste', les éléments intéressants de celle-ci, comme l'inévitable pointeur sur la tête de liste, mais aussi le pointeur sur la fin de la liste, ce qui permet un ajout en queue très rapide. D'autres informations (nombre d'éléments etc.) peuvent aussi accélérer les traitements.

Sélectionnez
struct list
{
   struct node *p_head;
   struct node *p_tail;
};

L'interface de la fonction d'ajout à la fin se trouve alors modifiée de la façon suivante :

Sélectionnez
void add_end (struct list *p_list, int value);

L'usage s'en trouve simplifié :

Sélectionnez
int main (void)
{
   struct list list = {0};

   add_end (&list, 1);
   add_end (&list, 2);
   add_end (&list, 3);
   display (&list);
   return 0;
}

Je laisse au lecteur le soin de réaliser les fonctions list_add_end(), list_display(), list_add_top() selon ce principe.

Il est aussi évidemment possible de rendre la structure opaque (voir l'article les types abstraits de données)

Sélectionnez
/* list.h */
typedef struct list list_s;
Sélectionnez
/* list.c */
#include "list.h"

/* structures privées */

struct node
{
   /* données */
   int x;

   /* chaînage */
   struct node *p_next;
};

struct list
{
   struct node *p_head;
   struct node *p_tail;
};

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Entrées solides en langage C

  • I. Introduction
  • II. Les fonctions d'entrées standards
  • III. Mise en évidence du fonctionnement de fgetc()
  • IV. Réalisation d'une fonction de lecture de lignes()
    • IV-A. Stocker les données reçues
    • IV-B. Créer une fonction de saisie
      • IV-B-1. Définition générale
      • IV-B-2. Définition de l'interface
      • IV-B-3. Définition du comportement
      • IV-B-4. Conception
      • IV-B-5. Codage et tests unitaires
        • IV-B-5-a. Interface, contrôle des paramètres
        • IV-B-5-b. Boucle de saisie, stockage
        • IV-B-5-c. Finalisation. Constantes pour les erreurs, traitement du <EOF>, compilation séparée
  • V. Conclusion

Cet article a pour but de mettre en évidence le comportement des fonctions de saisie, et d'élaborer des fonctions d'entrées solides à partir des fonctions d'entrées unitaires.

I. Introduction

Le langage C dispose de fonctions d'entrées standards dont l'usage est parfois surprenant, que ce soit par le comportement parfois inattendu de certaines fonctions, ou plus gravement par le risque de débordement lorsqu'une quantité de données trop importante est entrée.

Cet article a pour but de mettre en évidence le comportement des fonctions de saisie, et d'élaborer des fonctions d'entrées solides à partir des fonctions d'entrées unitaires.

II. Les fonctions d'entrées standards

Ce sujet est détaillé dans l'article Saisie de données par un opérateur (stdin). Il en ressort que la fonction de saisie unitaire est fgetc(), et qu'elle couvre tous les cas, puisqu'elle se contente d'extraire un byte du flux d'entrée. Son fonctionnement détaillé est expliqué dans l'article Comment fonctionne fgetc(stdin) alias getchar()

III. Mise en évidence du fonctionnement de fgetc()

Voici un petit programme qui permet de vérifier le fonctionnement de fgetc().

Dans le cas où stdin est connecté au clavier, la fin de lecture (EOF) est provoquée au clavier par la frappe d'une commande spéciale qui dépend du système :

  • MS-DOS, Windows : <Ctrl-Z><enter> (en debut de ligne) ;
  • Unixoides : <Ctrl-D>.
Sélectionnez
/* 01/main.c */

#include <stdio.h>

int main (void)
{
   int c;

   while ((c = fgetc (stdin)) != EOF)
   {
      fputc (c, stdout);
   }
   return 0;
}

Tous les essais suivants sont réalisés sous Windows XP avec l'IDE Code::Blocks (Mingw)

La frappe de 'abcdef<enter><Ctrl-Z><enter>' provoque cette sortie console :

Sélectionnez
abcdef
abcdef
^Z

Press ENTER to continue.

Afin de mieux 'voir ce qui se passe', on ajoute quelques éléments de visualisation (debug)

Sélectionnez
/* 02/main.c */

#include <stdio.h>
#include <ctype.h>

int main (void)
{
   int c;

   while ((c = getchar ()) != EOF)
   {
      if (isprint (c))
      {
         printf ("'%c'  (0x%02X)\n", c, (unsigned) c);
      }
      else
      {
         switch (c)
         {
         case '\n':
            printf ("'\\n' (0x%02X)\n", (unsigned) c);
            break;
         case '\t':
            printf ("'\\t' (0x%02X)\n", (unsigned) c);
            break;
         default:
            printf ("??  (0x%02X)\n", (unsigned) c);
         }
      }
   }
   return 0;
}

On obtient maintenant :

Sélectionnez
abcdef
'a'  (0x61)
'b'  (0x62)
'c'  (0x63)
'd'  (0x64)
'e'  (0x65)
'f'  (0x66)
'\n' (0x0A)
^Z

Press ENTER to continue.

On remarque que la frappe de <enter> provoque la fin de la suspension, et que l'intégralité des caractères frappés est extraite et affichée, y compris le '\n' qui marque la fin de la ligne.

On peut aussi constater que si on corrige sa frappe avec la touche <backspace>, la correction est gérée en interne, et qu'aucun caractère '\b' n'apparaît dans la liste des caractères entrés.

Enfin, on peut aussi constater que si on entre plusieurs lignes, celle-ci seront traitées complètement à chaque fois que l'on entre le caractère de fin de ligne (frappe de <enter>). Par exemple :

Sélectionnez
abc
'a'  (0x61)
'b'  (0x62)
'c'  (0x63)
'\n' (0x0A)
defg
'd'  (0x64)
'e'  (0x65)
'f'  (0x66)
'g'  (0x67)
'\n' (0x0A)
^Z

Press ENTER to continue.

Remarque importante. Le nombre de caractères saisi en une ligne peut être très important, et tant qu'il y a des caractères à lire, la boucle continue à les extraire.

Sous MS-DOS/Windows, il y a cependant une limite de 127 caractères (au-delà, le système émet un bip et la saisie est bloquée).

Mais cette limite est bien supérieure (voire indéterminée) sur une machine unixoide ou Windows NT. Il est donc prudent de ne faire aucune hypothèse sur une éventuelle limitation.

Voici un petit code qui permet de compter les caractères entrés à chaque ligne :

Sélectionnez
/* 03/main.c */

#include <stdio.h>

int main (void)
{
   int c;
   unsigned count = 0;

   while ((c = fgetc (stdin)) != EOF)
   {
      fputc (c, stdout);
      count ++;

      if (c == '\n')
      {
         printf ("%lu byte%s read\n", count, count > 1 ? "s" : "");
         count = 0;
      }
   }

   return 0;
}

Ce qui donne, par exemple :

Sélectionnez
1 byte read
abcd
abcd
5 bytes read
efghijkl
efghijkl
9 bytes read
^Z

Press ENTER to continue.

Sous XP, j'ai pu saisir une ligne de plus de 800 caractères sans problèmes...

IV. Réalisation d'une fonction de lecture de lignes()

IV-A. Stocker les données reçues

S'agissant de caractères, on va tout naturellement utiliser un tableau de char. Une question de conception se pose alors immédiatement : quelle taille donner au tableau ? Il n'y a pas de réponse universelle à cette question, à part "une taille infinie", ce qui n'a évidemment aucun sens. Dans un premier temps, on se contentera donc de la réponse laconique "une taille raisonnable". Évidemment, on prendra les précautions indispensables pour ne pas déborder du tableau.

Exemple : saisie d'un nom (32 caractères au maximum)

Sélectionnez
/* 10/main.c */
#include <stdio.h>
#include <assert.h>

int main (void)
{
   int c;

   /* bytes counter */
   unsigned long count = 0;

   /* index position */
   unsigned i = 0;

   /* array of char to store the line */
   char line[32 + 1];

   /* secure the array: put a sentinel */
   line[sizeof line - 1] = 0;

   while ((c = fgetc (stdin)) != EOF)
   {
      /* check the limit */
      if (i < sizeof line - 1)
      {
         /* store the data */
         line[i] = c;

         /* check the sentinel */
         assert (line[sizeof line - 1] == 0);

         /* next index position */
         i++;
      }
      else
      {
         puts ("full");
      }

      /* bytes counter */
      count++;

      if (c == '\n')
      {
         /* terminate the C-string */
         line[i] = 0;

         /* check the sentinel */
         assert (line[sizeof line - 1] == 0);

         /* print the line */
         printf ("'%s'\n", line);

         /* print the last line index */
         printf ("%u byte%s stored\n", i, i > 1 ? "s" : "");
         i = 0;

         /* print the bytes counter */
         printf ("%lu byte%s read\n", count, count > 1 ? "s" : "");
         count = 0;
      }
   }

   return 0;
}
Sélectionnez
aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
'
30 bytes stored
30 bytes read
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
full
full
full
full
full
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
32 bytes stored
37 bytes read
^Z

Press ENTER to continue.

Lors de la première saisie, l'ensemble des caractères lus, y compris le '\n' a été stocké et affiché.

Lors de la deuxième saisie, il y a eu 'saturation', et le programme indique que les cinq derniers caractères entrés ont été lus, mais non stockés. Ils sont perdus. Le '\n' est absent de la chaîne 'ligne'.

Nous pouvons donc affirmer que ce code est sûr, car :

  • il interdit le débordement du tableau ;
  • il lit tous les caractères saisis ;
  • il constitue une chaîne valide.

IV-B. Créer une fonction de saisie

IV-B-1. Définition générale

Soit à réaliser une fonction en C standard permettant la saisie d'une ligne de texte. L'utilisateur fournit un espace mémoire et la taille de celui-ci. La fonction assure la saisie des caractères, la lecture dans la mesure du possible, le stockage sous forme d'une chaîne valide (sans '\n' et avec 0 final) et la purge des caractères non lus. Elle retourne une valeur indiquant le bon fonctionnement ou non.

Dans le mesure du possible, tous les comportements seront définis, quelque soient les contraintes extérieures et les défauts systèmes.

La contrainte d'implémentation "en C standard" implique l'usage de fgetc() (ou une fonction dérivée de celle-ci), ce qui définit par avance une partie importante du comportement

IV-B-2. Définition de l'interface

La fonction est nommée 'get_line'.

Sélectionnez
get_line()

Elle retourne un int valant 0 en cas de succès, ou une valeur supérieure à 0 en cas d'échec.

Sélectionnez
int get_line()

Elle admet 2 paramètres : l'adresse du premier élément du tableau de destination et la taille de celui-ci. Les éléments du tableau étant de type char, le paramètre recevant l'adresse est de type char*. Le paramètre recevant la taille est de type size_t.

Sélectionnez
int get_line(char *s, size_t n)

IV-B-3. Définition du comportement

Une grande partie du comportement découle de celui de la fonction standard fgetc() qui sera utilisée pour l'implémentation. Lorsque la fonction est appelée, l'exécution du programme est suspendue en attente de l'entrée d'un caractère '\n' en provenance de stdin (Généralement, le clavier).

L'opérateur peut alors saisir des caractères. Il peut les modifier avec les commandes d'éditions standard du système (BACKSPACE, par exemple). Lorsqu'il veut terminer la saisie, il appuie sur la touche qui signifie 'fin de saisie' sur son système (ENTER, par exemple).

Les caractères sont alors lus jusqu'à premier '\n' rencontré (il correspond à l'appui de la touche 'fin de saisie'). Ils sont stockés dans la zone mémoire fournie par l'utilisateur à partir de l'indice 0 de s et ce, dans la limite de n - 1 caractères. Dans tous les cas si m caractères ont été placés dans le tableau, un 0 est placé à l'indice m de s.

La valeur retournée par défaut est 0. Une valeur supérieure à 0 est retournée en cas d'erreur.

A ce stade de la définition, il est difficile de prévoir tous les cas d'erreurs. Ceux-ci seront détaillés lors de l'implémentation

  • 0 :: Pas d'erreur ;
  • 1 : Paramètre erroné (pointeur NULL ou taille < 1) ;
  • 2 : Un débordement potentiel a été détecté (la taille maximale a été atteinte et des caractères autres que '\n' ont donc été lus sans être stockés) ;
  • 3 : Une fin de saisie brutale a été détectée (la saisie a été brutalement interrompue par l'opérateur, par exemple par l'appui de Ctrl-Z ou Ctrl-D selon le système).

IV-B-4. Conception

L'algorithme suivant décrit le comportement demandé (les cas d'erreurs ne sont pas traités ici)

Sélectionnez
FAIRE
 lire un caractère
 SI la place est suffisante
  le placer dans le tableau
 FIN SI
TANT QUE le caractère de fin de ligne, n'a pas été détecté
Placer un 0 dans le tableau après le dernier caractère stocké.

Il serait possible de décrire un algorithme plus détaillé, mais étant donné sa simplicité, l'implémentation directe devrait suffire.

IV-B-5. Codage et tests unitaires

L'implémentation est faite selon la méthode XP, à savoir la programmation par contrat. Un test unitaire est associé à chaque étape du développement. Ces étapes sont détaillées ici :

IV-B-5-a. Interface, contrôle des paramètres

Sélectionnez
/* 11/main.c */
#include <stdio.h>

/* device under test */

/* interface */
int get_line(char *s, size_t n);

/* implementation */
int get_line(char *const s, size_t const n)
{
   int err = 0;

   if (s != NULL && n > 0)
   {
      /* to be continued ... */
   }
   else
   {
      err = 1;
   }

   return err;
}

/* unit test */

#define NELEM(a) (sizeof(a)/sizeof *(a))

int main (void)
{
   struct test
   {
      int tnum;

      /* parameters */
      char *s;
      size_t n;

      /* return */
      int err;
   };

   static char s[32];
   static const struct test a[] =
      {
         {
            1, NULL, 0, 1
         },
         {
            2, s, 0, 1
         },
         {
            3, s, 1, 0
         },
         {
            4, s, sizeof s, 0
         },
      };
   size_t i;
   int terr = 0;

   for (i = 0; i < NELEM (a) && !terr; i++)
   {
      struct test const *const p = a + i;

      int err = get_line (p->s, p->n);

      if (err != p->err)
      {
         printf ("ERR at test %d\n", p->tnum);
         terr = 1;
      }
   }

   if (!terr)
   {
      puts ("\nP A S S E D\n");
   }

   return 0;
}

IV-B-5-b. Boucle de saisie, stockage

On ajoute la boucle de saisie et le stockage avec contrôle de limites.

Sélectionnez
/* 12/main.c */
#include <stdio.h>

/* device under test */

/* interface */
int get_line(char *s, size_t n);

/* implementation */
int get_line(char *const s, size_t const n)
{
   int err = 0;

   if (s != NULL && n > 0)
   {
      int c;
      size_t w = 0;

      while ((c = fgetc (stdin)) != '\n' && c != EOF)
      {
         if (w < n - 1)
         {
            s[w] = c;
            w++;
         }
         else
         {
            err = 2;
         }
      }
      s[w] = 0;
   }
   else
   {
      err = 1;
   }

   return err;
}

/* unit test */
#include <string.h>

#define NELEM(a) (sizeof(a)/sizeof *(a))

int main (void)
{
   struct test
   {
      int tnum;

      /* parameters */
      char *s;
      size_t n;

      /* return */
      int err;

      /* input string */
      char const *sin;

      /* output string */
      char const *sout;

   };

   static char s[8];
   static const struct test a[] =
      {
         /* wrong parameters */
         {
            1, NULL, 0, 1, NULL, NULL
         },
         {
            2, s, 0, 1, NULL, NULL
         },

         /* normal parameters */
         {
            10, s, sizeof s, 0, "", ""
         },
         {
            11, s, sizeof s, 0, "1", "1"
         },
         {
            12, s, sizeof s, 0, "1234567", "1234567"
         },
         {
            13, s, sizeof s, 2, "12345678", "1234567"
         },
         {
            14, s, sizeof s, 0, "abc", "abc"
         },
      };
   size_t i;
   int terr = 0;

   for (i = 0; i < NELEM (a) && !terr; i++)
   {
      struct test const *const p = a + i;

      int err;

      if (p->sin != NULL)
      {
         printf ("Test %d : entrer '%s' puis <ENTER>\n", p->tnum, p->sin);

         memset (s, '?', sizeof s);
         s[sizeof s - 1] = 0;
      }

      err = get_line (p->s, p->n);

      if (err != p->err)
      {
         printf ("ERR at test %d\n", p->tnum);
         terr = 1;
      }
      else
      {
         if (p->sin != NULL)
         {
            if (strcmp(s, p->sout) != 0)
            {
               printf ("ERR at test %d: s='%s' sout='%s'\n", p->tnum, s, p->sout);
               terr = 1;
            }
            else
            {
               printf ("OK: '%s'\n", s);
               printf ("err = %d\n", err);
            }
         }
      }
   }

   if (!terr)
   {
      puts ("\nP A S S E D\n");
   }

   return 0;
}

IV-B-5-c. Finalisation. Constantes pour les erreurs, traitement du <EOF>, compilation séparée

Fichier d'interface get_line.h

Sélectionnez
#ifndef H_GET_LINE
#define H_GET_LINE

/* get_line.h */
#include <stddef.h>

/* return codes */
enum
{
   GET_LINE_OK,
   GET_LINE_ERR_PARAM,
   GET_LINE_ERR_TOO_LONG,
   GET_LINE_ERR_EOF,
   GET_LINE_ERR_NB
};

/* interface */
int get_line(char *s, size_t n);

#endif /* guard */

Fichier d'implémentation get_line.c

Sélectionnez
/* get_line.c */

#include "get_line.h"
#include <stdio.h>

/* implementation */
int get_line(char *const s, size_t const n)
{
   int err = GET_LINE_OK;

   if (s != NULL && n > 0)
   {
      int c;
      size_t w = 0;

      while ((c = fgetc (stdin)) != '\n' && c != EOF)
      {
         if (w < n - 1)
         {
            s[w] = c;
            w++;
         }
         else
         {
            if (!err)
            {
               err = GET_LINE_ERR_TOO_LONG;
            }
         }
      }
      s[w] = 0;

      if (c == EOF)
      {
         err = GET_LINE_ERR_EOF;
      }
   }
   else
   {
      err = GET_LINE_ERR_PARAM;
   }

   return err;
}

Fichier de test unitaire main.c

Sélectionnez
/* 13/main.c */

/* unit test for getline() */

#include "get_line.h"
#include <stdio.h>
#include <string.h>

#define NELEM(a) (sizeof(a)/sizeof *(a))

int main (void)
{
   struct test
   {
      int tnum;

      /* parameters */
      char *s;
      size_t n;

      /* return */
      int err;

      /* input string */
      char const *sin;

      /* output string */
      char const *sout;

   };

   static char s[8];
   static const struct test a[] =
      {
         /* wrong parameters */
         {
            1, NULL, 0, GET_LINE_ERR_PARAM, NULL, NULL
         },
         {
            2, s, 0, GET_LINE_ERR_PARAM, NULL, NULL
         },

         /* normal parameters */
         {
            10, s, sizeof s, GET_LINE_OK, "", ""
         },
         {
            11, s, sizeof s, GET_LINE_OK, "1", "1"
         },
         {
            12, s, sizeof s, GET_LINE_OK, "1234567", "1234567"
         },
         {
            13, s, sizeof s, GET_LINE_ERR_TOO_LONG, "12345678", "1234567"
         },
         {
            14, s, sizeof s, GET_LINE_OK, "abc", "abc"
         },
         {
            15, s, sizeof s, GET_LINE_ERR_EOF, "<EOF>", ""
         },
      };
   size_t i;
   int terr = 0;

   for (i = 0; i < NELEM (a) && !terr; i++)
   {
      struct test const *const p = a + i;

      int err;

      if (p->sin != NULL)
      {
         printf ("Test %d : entrer '%s' puis &ENTER>\n", p->tnum, p->sin);

         memset (s, '?', sizeof s);
         s[sizeof s - 1] = 0;
      }

      /* device under test */
      err = get_line (p->s, p->n);

      if (err != p->err)
      {
         printf ("ERR on returned value at test %d\n", p->tnum);
         terr = 1;
      }
      else
      {
         if (p->sin != NULL)
         {
            if (strcmp(s, p->sout) != 0)
            {
               printf ("ERR at test %d: s='%s' sout='%s'\n", p->tnum, s, p->sout);
               terr = 1;
            }
            else
            {
               printf ("OK: '%s'\n", s);
               printf ("err = %d\n", err);
            }
         }
      }
   }

   if (!terr)
   {
      puts ("\nP A S S E D\n");
   }

   return 0;
}

V. Conclusion

Nous disposons maintenant d'une fonction de base permettant la saisie d'une ligne de taille fixe de façon simple et fiable. Des améliorations sont possibles, comme la généralisation à tout fichier texte (simple), ou la saisie d'une ligne de longueur arbitraire (un peu plus complexe, et mettant en œuvre l'allocation dynamique).

Je laisse au lecteur le soin de poursuivre l'expérience. Des solutions plus ou moins complexes sont proposées ici (Module IO)

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Développement d'un projet informatique

  • I. Introduction
  • II. Un peu de réflexion et du bon sens...
    • II-A. Définition
    • II-B. Analyse descendante
    • II-C. Construction montante
    • II-D. Vérification
  • III. Cycle en V
    • III-A. Le document de définition
    • III-B. Le document de conception
    • III-C. Les fichiers sources et les tests unitaires
    • III-D. Le cahier de test d'intégration
    • III-E. Le document de validation
    • III-F. Pourquoi en V ?
  • IV. Critique de la méthode du cycle en V
  • V. Méthodes dites 'agiles'
    • V-A. Introduction
    • V-B. Les itérations courtes
    • V-C. La conception par les tests
    • V-D. La réécriture
    • V-E. Le travail en binôme
    • V-F. Le travail en équipe

Cet article présente un certain nombre de règles simples qui permettent de gérer un projet informatique de façon optimale et maîtrisée.

I. Introduction

L'aboutissement d'un projet informatique est une opération complexe qui mobilise des moyens importants en terme de temps et de personnel, donc de budget. Si on y prend pas garde, on se retrouve facilement en dépassement de délai et de coût. De plus, une mauvaise étude peut mener à un résultat non conforme à la demande.

Il convient donc d'agir avec le plus d'efficacité possible. Pour cela, il existe un certain nombre de règles simples qui permettent de gérer le projet de façon optimale et maîtrisée.

II. Un peu de réflexion et du bon sens...

Le bon sens est la chose la mieux partagée au monde

Il est un principe de base et de bon sens qui veut que

ce qui se conçoit bien s'énonce clairement

Ce principe, énoncé par un poète français du 17ème siècle (Nicolas Boileau), prend tout son sens dès qu'il s'agit de réaliser le moindre projet. Il dit, en substance, que l'on ne peut réaliser quoique ce soit de sérieux si on a pas pris la peine au préalable de définir ce que l'on veut faire.

On peut y adjoindre les 4 préceptes du Discours de la Méthode de Descartes :

Ainsi, au lieu de ce grand nombre de préceptes dont la logique est composée, je crus que j'aurais assez des quatre suivants, pourvu que je prisse une ferme et constante résolution de ne manquer pas une seule fois à les observer.

II-A. Définition

Le premier était de ne recevoir jamais aucune chose pour vraie, que je ne la connusse évidemment être telle : c'est-à-dire, d'éviter soigneusement la précipitation et la prévention ; et de ne comprendre rien de plus en mes jugements, que ce qui se présenterait si clairement et si distinctement à mon esprit, que je n'eusse aucune occasion de le mettre en doute.

II-B. Analyse descendante

Le deuxième, de diviser chacune des difficultés que j'examinerais , en autant de parcelles qu'il se pourrait, et qu'il serait requis pour les résoudre.

II-C. Construction montante

Le troisième, de conduire par ordre mes pensées, en commençant par les objets les plus simples et les plus aisés à connaître, pour monter peu, comme par degrés, jusques à la connaissance des plus composés ; et supposant même l'ordre entre ceux qui ne se précèdent point naturellement les uns les autres.

II-D. Vérification

Et le dernier, de faire partout des dénombrements si entiers, et des revues si générales, que je fusse assuré de ne rien omettre.
Ces longues chaînes de raisons, toutes simples et faciles, dont les géomètres ont coutume de se servir, pour parvenir à leurs plus difficiles démonstrations, m'avaient donné occasion de m'imaginer que toutes les choses, qui peuvent tomber sous la connaissance des hommes, s'entre-suivent en même façon et que, pourvu seulement qu'on s'abstienne d'en recevoir aucune pour vraie qui ne le soit, et qu'on garde toujours l'ordre qu'il faut pour les déduire les unes des autres, il n'y en peut avoir de si éloignées auxquelles enfin on ne parvienne, ni de si cachées qu'on ne découvre.

Qui se résument en :

Ne recevoir aucune chose pour vraie tant que son esprit ne l'aura clairement et distinctement assimilé préalablement
Trier ses difficultés afin de mieux les examiner et les résoudre
Établir un ordre de pensées, en commençant par les plus simples jusqu'aux plus complexes et diverses, et ainsi de les retenir toutes et en ordre
Passer toutes les choses en revue afin de ne rien omettre

Cette définition préalable (ou plus simplement "Définition") prend différents noms selon les métiers :

  • objectifs ;
  • cahier des charges ;
  • spécifications ;
  • document de définition.

Une fois que ce document est écrit, on va pouvoir concevoir le produit, c'est à dire trouver les moyens de réaliser les objectifs décrits dans la définition. On passe alors en phase d'analyse, ce qui se traduit par le découpage du produit en unités de plus en plus petites selon une sorte d'arborescence.

Les petits éléments deviennent alors des sortes de briques qu'il suffit de réaliser puis d'assembler pour réaliser le projet.

III. Cycle en V

Le cycle en V est une formalisation du cycle de développement mettant en œuvre 5 étapes :

  • définition ;
  • conception ;
  • réalisation ;
  • intégration ;
  • validation.

Chaque étape est matérialisée par un document :

  • le document de définition ;
  • le document de conception ;
  • les fichiers sources et les tests unitaires ;
  • le cahier de tests d'intégration ;
  • le document de validation.

III-A. Le document de définition

Ce document décrit noir sur blanc le produit attendu en termes de :

  • environnement ;
  • interfaces ;
  • comportement ;
  • performances ;
  • coûts et délais.

Il répond à la question "Quoi ?"

Ce document est connu du client et signé par celui-ci.

III-B. Le document de conception

Ce document décrit noir sur blanc les moyens mis en œuvre pour réaliser le produit :

  • découpage arborescent en blocs fonctionnels ;
  • architecture logicielle ;
  • comportement détaillé (algorithmes, machines à états) ;
  • fonctions (interfaces et comportement).

Il répond à la question "Comment ?"

Sauf indication contraire, ce document est interne.

III-C. Les fichiers sources et les tests unitaires

C'est le résultat du codage des fonctions. L'organisation du source ainsi que les interfaces des fonctions publiques (points d'entrées) découlent de l'analyse résultant de la conception. Chaque bloc fonctionnel terminal (feuille de l'arbre) est testé unitairement. Il est conçu pour être autonome, par exemple sous la forme d'un composant logiciel (lire l'article Concevoir et réaliser un composant logiciel en C). Si il a des sorties, celles-ci sont le plus souvent réalisées sous forme d'appels à des fonctions externes via un pointeur de fonction, ce qui autorise les tests unitaires sans connaître les détails de l'application.

Sauf indication contraire, ces documents sont internes.

III-D. Le cahier de test d'intégration

L'intégration consiste à rassembler les composants logiciels dans le but de réaliser le produit final. Une liste de tests extrêmement poussés permet de valider la conception en fonctionnement normal, aux limites et au-delà. L'ensemble de ces tests et leurs résultats sont consignés dans le cahier de test d'intégration. C'est ici que se fait la mise au point du produit. Si un composant logiciel doit être corrigé (modifications de spécifications détaillées), le test unitaire est repassé (éventuellement complété) afin de s'assurer qu'il n'y a pas de régression et que le nouvel objectif est bien atteint.

Sauf indication contraire, ce document est interne.

III-E. Le document de validation

Le document de validation est une liste de tests 'boite noire' (c'est à dire que la conception est ignorée) tendant à prouver la conformité du produit avec la demande du client. Il s'appuie bien sûr sur le document de définition. On parle aussi de cahier de recette. Le fournisseur s'engage à réaliser les tests mentionnés et à en indiquer les résultats. Il signe le document. C'est en quelque sorte la garantie constructeur. En cas de litige, le client peut exiger qu'un test réputé réussi ou présentant des résultats connus, soit repassé devant lui à des fins de vérification.

Ce document est connu du client et signé par le fournisseur et par le client.

III-F. Pourquoi en V ?

Le terme « en V » est issu de la mise en regard de chaque document et de leur portée :

  • la validation est en regard de la spécification ;
  • l'intégration est en regard de la conception ;
  • la spécification et la validation sont du domaine du client.

Il en résulte le schéma suivant :

Sélectionnez
1 - Définition       Validation - 5
         \               /               Domaine Client/Fournisseur
------------------------------------------------------------------
           \           /                Domaine Fournisseur
  2 - Conception Intégration - 4
             \       /
        3 - Codage et TU

IV. Critique de la méthode du cycle en V

Théoriquement, la méthode en V est parfaite. Elle décrit un processus bien huilé qui transforme l'idée du client en produit fini.

Dans la pratique, l'expérience montre que, loi de Murphy aidant, rien ne se passe comme prévu !

De nombreux facteurs s'acharnent à faire dériver le projet, que ce soit en matière de coût et de délai, mais aussi, par exemple, en terme de faisabilité de telle ou telle fonctionnalité ou d'erreur de conception grave (mauvais choix technologique, par exemple).

Tout le problème est donc de trouver le moyen d'identifier rapidement les obstacles et autres points bloquants. Il existe pour ça des méthodes dites 'agiles' qui permettent d'obtenir des résultats bien meilleurs que la méthode en V classique appliquée brutalement.

Ceci dit, les principes de la méthode en V sont bons, mais ils ne doivent pas être appliqués directement à la réalisation de gros projets, mais sont utilisés d'une manière simplifiée mais rigoureuse pour la réalisation des itérations courtes telles que les recommandent les méthodes dites « agiles ».

V. Méthodes dites 'agiles'

V-A. Introduction

Les méthodes agiles sont basées sur l'expérience et le bon sens. Plusieurs idées maîtresses régissent ces méthodes. Elles visent avant tout à obtenir un résultat, et non à appliquer un formalisme rigide. La méthode est centrée sur le produit. Elle n'est qu'un outil au service de la réalisation et non une fin en soi.

Les principales caractéristiques sont :

  • les itérations courtes ;
  • la conception par les tests ;
  • la réécriture ;
  • le travail en binôme ;
  • le travail en équipe.

Plus d'informations sur les méthodes agiles :: Cours sur les Méthodes Agiles

V-B. Les itérations courtes

C'est le point fort qui fait la différence avec les méthodes traditionnelles. Il s'agit, à partir des spécifications générales, de définir rapidement un projet aux fonctionnalités minimales mais essentielles et surtout centrées sur les aspects innovants (vu du réalisateur), de façon à montrer rapidement la faisabilité du projet (maquettage). Cela permet très rapidement (en quelques semaines) de savoir si le projet est viable ou non.

Le Chef de Projet (CP) fixe des objectifs précis avec des délais réalistes et un point d'avancement est fait chaque semaine. Si une dérive ou un point bloquant est constaté, les compétences nécessaires sont mises à disposition pour résoudre le problème (si c'est possible). On peut très bien tomber sur un échec. Mais il vaut mieux le savoir au bout de trois semaines qu'au bout de trois ans (des boites ont coulé pour moins que ça…)

D'autre part, c'est l'occasion de tester la spécification et de demander au client des précisions sur les points qui auraient échappé à la rédaction du cahier des charges. Là encore, il vaut mieux demander les précisions au début du projet qu'au bout de 1 ou 2 ans. Question de crédibilité…

Dès qu'une itération est terminée (et même avant), le CP planifie l'itération suivante selon les mêmes critères. On commence par le plus difficile (ou le moins connu), et on laisse le code routinier pour la fin. (Les risques de tomber sur des problèmes de conception et/ou de réalisation sont moindres).

V-C. La conception par les tests

Ce principe s'applique à la réalisation d'une fonction ou d'un groupe de fonction (classe gérant un objet, par exemple). Il est admis que lorsque l'on réalise du code, celui-ci doit être testé unitairement. Les méthodes agiles proposent d'aller plus loin en intégrant le test dans le conception :

  1. rédaction de la spécification détaillée ;
  2. rédaction des tests permettant de vérifier l'interface et le comportement nominal, aux limites, hors limites ;
  3. codage de l'environnement de test selon des méthodes simples et éprouvées (plus ou moins automatisées) ;
  4. codage des tests avec un emplacement pour le DUT (Device Under Test). L'appel doit se faire dans les conditions d'une application ;
  5. codage de la ou des fonctions dans des modules séparés, évidemment.

V-D. La réécriture

Le code n'est pas immuable. Plutôt que de le corriger à l'infini, il vaut mieux parfois admettre qu'il faut réécrire une portion de code (refactoring).

V-E. Le travail en binôme

Quand c'est possible, il est souhaitable que le codage se fasse en binôme. L'un tient le clavier et ne fait que taper le code. L'autre tient le document de spécification, guide le codeur dans les grandes lignes et vérifie ce qui est tapé (conformité au langage, etc.) C'est comme ça qu'on élimine les problèmes de comportement indéfini.

V-F. Le travail en équipe

Quand c'est possible, il est souhaitable que l'équipe ne se spécialise pas dans tel ou tel domaine du projet et qu'il y ait des rotations de façons à ce que la connaissance soit partagée. C'est très utile en cas de défaillance d'un des équipiers et chacun est obligé d'avoir le même niveau de compétence que son voisin et inversement. Le niveau général augmente, ce qui est une Bonne Chose (©).

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Note sur le C

Opérateurs bit à bit en langage C

  • I. Description des opérateurs bits à bits
    • I-A. Introduction
    • I-B. NOT (NON)
    • I-C. AND (ET)
    • I-D. OR (OU)
    • I-E. XOR (OU exclusif)
    • I-F. SHR (Décalage à droite)
    • I-G. SHL (Décalage à gauche)
  • II. Usages des opérateurs bits à bits
    • II-A. Manipulations de l'état des bits d'une variable
    • II-B. Positionner un bit à 1
    • II-C. Positionner un bit à 0
    • II-D. Tester la valeur d'un bit
    • II-E. Conclusion

Cet article a pour but de présenter les opérateurs bit à bit et leurs utilisations en langage C.

I. Description des opérateurs bits à bits

I-A. Introduction

Toute donnée informatique est stockée en mémoire sous la forme d'une combinaison de bits. Par exemple un entier valant 10 (base 10) implémenté par une mémoire d'une largeur de 16-bit contient :

Sélectionnez
0000 0000 0000 1010

Soit en hexadécimal :

Sélectionnez
000A

Les opérateurs bits permettent de modifier et de tester un ou plusieurs bits d'une donnée. Ces opérateurs sont :

  • NOT (NON) ;
  • AND (ET) ;
  • OR (OU) ;
  • XOR (OU exclusif) ;
  • SHR (décalage à droite) ;
  • SHL (décalage à gauche).

I-B. NOT (NON)

L'opérateur unaire NOT inverse l'état d'un bit selon le tableau suivant :

Table de vérité NOT
A | NOT A
0 | 1
1 | 0

L'opérateur C est ~. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 1;
unsigned b = ~a; /* b == 1111111111111110  (en supposant 16-bit) */

I-C. AND (ET)

L'opérateur binaire AND combine l'état de 2 bits selon le tableau suivant :

Table de vérité AND
A | B | A AND B
0 | 0 | 0
0 | 1 | 0
1 | 0 | 0
1 | 1 | 1

L'opérateur C est &. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a & b; /* c == 0000000011110000 soit 0x00F0 */

I-D. OR (OU)

L'opérateur binaire OR combine l'état de 2 bits selon le tableau suivant :

Table de vérité OR
A | B | A OR B
0 | 0 | 0
0 | 1 | 1
1 | 0 | 1
1 | 1 | 1

L'opérateur C est |. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a | b; /* c == 1111 0000 1111 1111 soit 0xF0FF */

I-E. XOR (OU exclusif)

L'opérateur binaire OR combine l'état de 2 bits selon le tableau suivant :

Table de vérité XOR
A | B | A XOR B
0 | 0 | 0
0 | 1 | 1
1 | 0 | 1
1 | 1 | 0

L'opérateur C est ^. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 0xF0F0;
unsigned b = 0x00FF;
unsigned c = a ^ b; /* c == 1111 0000 0000 1111 soit 0xF00F */

I-F. SHR (Décalage à droite)

L'opérateur binaire SHR a pour opérande de gauche la valeur initiale et pour opérande de droite le nombre de bits à décaler à droite. Les bits de poids faibles sont perdus et les bits de poids forts entrés (à gauche) sont à 0. Ce n'est pas une rotation.

L'opérateur C est >>. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 0xF0F0;
unsigned b = 2;
unsigned c = a >> b; /* c == 0011 1100 0011 1100 soit 0x3C3C */

I-G. SHL (Décalage à gauche)

L'opérateur binaire SHL a pour opérande de gauche la valeur initiale et pour opérande de droite le nombre de bits à décaler à gauche. Les bits de poids forts sont perdus et les bits de poids faibles entrés (à droite) sont à 0. Ce n'est pas une rotation.

L'opérateur C est <<. Il agit sur chaque bit de la valeur :

Sélectionnez
unsigned a = 0xF0F0;
unsigned b = 2;
unsigned c = a << b; /* c == 1100 0011 1100 0000 soit 0xC3C0 */

II. Usages des opérateurs bits à bits

II-A. Manipulations de l'état des bits d'une variable

Si la variable est entière et non signée, il est possible d'utiliser les opérateurs bits pour mettre un ou des bits à 0 ou à 1. Les usages connus sont :

  • compression de données ;
  • commande et état de registres matériels ;
  • etc.

II-B. Positionner un bit à 1

Le principe est de combiner la valeur avec un masque grâce à l'opérateur OU. En effet, comme l'indique la table de vérité, les bits à 0 du masque vont laisser la valeur initiale inchangée, alors les bits à 1 vont s'imposer.

Sélectionnez
/* mettre à 1 le bit 4 de b : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */
   unsigned b = 0x0010; /* 0000 0000 0001 0000 */
   unsigned c = a | b;  /* 0000 0000 0001 1111 soit 0x001F */

   printf ("%04X OU %04X = %04X\n, a, b, c);

Pour fabriquer le masque, il suffit d'utiliser un 1 que l'on décale à gauche de la valeur correspondante au poids du bit. Par exemple :

Sélectionnez
Bit 0  : 1u << 0  = 0000 0000 0000 0001
Bit 2  : 1u << 2  = 0000 0000 0000 0100
Bit 15 : 1u << 15 = 1000 0000 0000 0000

Comme pour toute manipulation de bits (y compris avec des constantes), on utilise des valeurs non signées (d'où le 'u').

II-C. Positionner un bit à 0

Le principe est de combiner la valeur avec un masque grâce à l'opérateur ET. En effet, comme l'indique la table de vérité, les bits à 1 du masque vont laisser la valeur initiale inchangée, alors les bits à 0 vont s'imposer.

Sélectionnez
/* mettre à 0 le bit 3 de b : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */
   unsigned b = 0xFFF7; /* 1111 1111 1111 0111 */
   unsigned c = a & b;  /* 0000 0000 0000 0111 soit 0x0007 */

   printf ("%04X OU %04X = %04X\n, a, b, c);

Pour fabriquer le masque, il suffit d'utiliser un 1 que l'on décale à gauche de la valeur correspondante au poids du bit, puis on inverse les bits avec l'opérateur NON. Par exemple :

Sélectionnez
Bit 0  : ~(1u << 0)  = 1111 1111 1111 1110
Bit 2  : ~(1u << 2)  = 1111 1111 1111 1011
Bit 15 : ~(1u << 15) = 0111 1111 1111 1111

II-D. Tester la valeur d'un bit

Le principe est d'évaluer le résultat entre la valeur à tester d'une part et un masque à 0, sauf le bit à tester, avec l'opérateur AND. Les bits a 0 restent à 0. Le bit à un passe à un si la valeur lue est 1, sinon, il reste à 0. Si le résultat est 0, le bit est donc à 0. Si il n'est pas 0, il est à 1.

Sélectionnez
/* tester l'état du bit 2 de a : */
   unsigned a = 0x000F; /* 0000 0000 0000 1111 */

   if (a & (1u << 2))
   {
      puts("bit 2 = 1");
   }
   else
   {
      puts("bit 2 = 0");
   }

   /* on peut aussi directement récupérer la valeur 0 ou 1
      à l'aide des propriétés de l'opérateur logique ! : */
   printf ("bit 2 = %d\n, !!(a & (1u << 2)));

II-E. Conclusion

Je laisse au lecteur le soin de refaire ces exercices, et trouver le moyen de positionner et tester plusieurs bits d'une même variable.

Ces macros permettent une manipulation aisée des bits d'un entier jusqu'à 32-bit.

Liste des articles d'Emmanuel Delahaye

  • Initiation Pragmatique au langage C
  • Bonnes pratiques de codage en C
  • Bibliothèque CLIB
  • Notes sur le langage C
  • Concevoir et réaliser un composant logiciel en C
  • Les types abstraits de données (ADT)
  • Notes sur les réseaux
  • L'outil MAKE
  • POSIX threads
  • Les listes chaînées en langage C
  • Entrées solides en langage C
  • Introduction au développement d'un projet informatique
  • Opérateurs bit à bit


Sommaire

BONJOUR
Musique "Invention BWV988" J.S. BACH 1685-1750

RC Resource and Compiler